#!/usr/bin/env python3
"""Fail-closed local control plane for solwebd (no remote/profile copying)."""
from __future__ import annotations
import argparse, fcntl, hashlib, json, os, secrets, stat, subprocess, sys, time, urllib.request, urllib.error, uuid
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT))
import browser, chat
STATE=Path(os.environ.get("OVERDECK_GPTBRIDGE_STATE", Path.home()/".overdeck/gptbridge"))
IDENTITY=STATE/'identity.json'; TOKEN=STATE/'solwebd.token'; RUNTIME=STATE/'runtime.json'
MODELS={f'sol-web-{x}' for x in ('medium','high','xhigh','pro')}; ENDPOINT='http://127.0.0.1:8791'
def fail(s): raise RuntimeError(s)
def private(p, required=True):
 try: st=os.lstat(p)
 except FileNotFoundError:
  if required: fail(f'missing private state: {p}')
  return False
 if not stat.S_ISREG(st.st_mode) or st.st_uid!=os.getuid() or stat.S_IMODE(st.st_mode)!=0o600: fail(f'insecure private state: {p}')
 return True
def atomic(p, data):
 STATE.mkdir(mode=0o700,parents=True,exist_ok=True)
 if STATE.is_symlink(): fail('state directory may not be a symlink')
 if p.exists() or p.is_symlink(): private(p)
 tmp=STATE/(p.name+'.'+uuid.uuid4().hex)
 fd=os.open(tmp,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
 with os.fdopen(fd,'w') as f: f.write(data); f.flush(); os.fsync(f.fileno())
 os.replace(tmp,p)
def identity():
 private(IDENTITY); data=json.loads(IDENTITY.read_text())
 if set(data)!={'expected_account','profile_dir','machine_id_sha256'}: fail('invalid identity')
 machine=hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()
 if machine!=data['machine_id_sha256'] or str(browser.source_profile().resolve())!=data['profile_dir']: fail('enrollment identity mismatch')
 return data
def token():
 private(TOKEN); value=TOKEN.read_text().strip()
 if not value or any(c.isspace() for c in value): fail('invalid token')
 return value
def proc_ticks(pid):
 # stat's comm can contain spaces/parentheses: fields after final ')' begin at field 3.
 try: raw=Path(f'/proc/{pid}/stat').read_text(); return int(raw.rsplit(')',1)[1].split()[19])
 except (FileNotFoundError,ValueError,IndexError): return None
def owned_command(pid):
 try:
  argv=Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0')
  script=(ROOT/'solwebd.py').resolve()
  try: exe=Path(f'/proc/{pid}/exe').resolve(strict=True)
  except PermissionError: return None
  if exe!=Path(sys.executable).resolve(): return False
  if len(argv)>2 and argv[1]==b'-s' and argv[2] and not argv[2].startswith(b'-'):
   return Path(os.fsdecode(argv[2])).resolve()==script
  isolated=b"import runpy,sys;root=sys.argv.pop(1);script=sys.argv.pop(1);sys.path.insert(0,root);sys.argv[0]=script;runpy.run_path(script,run_name='__main__')"
  if len(argv)>5 and argv[1:4]==[b'-I',b'-c',isolated]:
   return Path(os.fsdecode(argv[4])).resolve()==ROOT.resolve() and Path(os.fsdecode(argv[5])).resolve()==script
  return False
 except (FileNotFoundError,OSError,ValueError): return False
def empty(detail='not running'):
 return {'ok':False,'state':'degraded','endpoint':ENDPOINT+'/v1','host':'127.0.0.1','account':None,'seats':0,'queue':0,'active':0,'models':[],'authenticated':False,'pid':None,'process_start_ticks':None,'instance_generation':None,'version':'unknown','detail':detail}
def owned_runtime(allow_authenticated_probe=False):
 """Read private generation receipt; unreadable executable requires authenticated health proof."""
 private(RUNTIME)
 runtime=json.loads(RUNTIME.read_text())
 pid=int(runtime['pid']); ticks=proc_ticks(pid); command=owned_command(pid)
 if ticks is None or ticks != runtime.get('process_start_ticks') or command is False or (command is None and not allow_authenticated_probe):
  return None
 if not runtime.get('instance_generation'):
  fail('runtime has no generation')
 return runtime

def health(legacy=False):
 try:
  ident=None if legacy else identity(); tok=token(); private(RUNTIME)
  runtime=owned_runtime(allow_authenticated_probe=True)
  if runtime is None: return empty('stale runtime pid')
  pid=int(runtime['pid']); ticks=runtime['process_start_ticks']
  req=urllib.request.Request(ENDPOINT+'/healthz',headers={'Authorization':'Bearer '+tok})
  with urllib.request.urlopen(req,timeout=3) as r: data=json.loads(r.read())
  required={'state','endpoint','host','account','seats','queue','active','models','authenticated','pid','process_start_ticks','instance_generation','version'}
  if not required <= data.keys(): return empty('malformed health response')
  owned=(data['pid']==pid and data['process_start_ticks']==ticks and data['instance_generation']==runtime.get('instance_generation'))
  data['ok']=bool(data.get('state')=='ready' and owned and data.get('authenticated') and (legacy or data.get('account')==ident['expected_account']) and MODELS <= set(data.get('models',[])))
  if not data['ok']: data.setdefault('detail','identity, ownership, authentication, or models unhealthy')
  return data
 except Exception as e: return empty(str(e))
def emit(data): print(json.dumps(data,separators=(',',':')))
def enroll():
 profile=str(browser.source_profile().resolve())
 # The browser seam is deliberately used rather than trusting profile metadata.
 with browser.Session(mode='virtual') as m:
  # authenticated_account uses a relative session endpoint; it is valid only
  # after the profile has reached chatgpt.com.
  m.navigate('https://chatgpt.com/')
  chat.wait_ready(m)
  chat.use_chat_surface(m)
  account=chat.authenticated_account(m)
 if not account: fail('profile is logged out')
 data={'expected_account':account.lower(),'profile_dir':profile,'machine_id_sha256':hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()}
 if private(IDENTITY,False) and json.loads(IDENTITY.read_text()) != data: fail('existing enrollment differs')
 if not IDENTITY.exists(): atomic(IDENTITY,json.dumps(data))
 if not TOKEN.exists(): atomic(TOKEN,secrets.token_urlsafe(32)+'\n')
 else: token()
 return data
def ensure(seats, legacy=False):
 if seats<1: fail('seats must be positive')
 if legacy:
  # Legacy headerless callers have no enrollment pin, but still require a real
  # local authenticated daemon once it is running.
  if not TOKEN.exists(): atomic(TOKEN,secrets.token_urlsafe(32)+'\n')
 else:
  identity()
 token(); STATE.mkdir(mode=0o700,parents=True,exist_ok=True)
 with open(STATE/'startup.lock','a+') as lock:
  fcntl.flock(lock,fcntl.LOCK_EX)
  current=health(legacy)
  if current['ok']:
   if current['seats']<seats: fail('active daemon has insufficient seats')
   return current
  # Never start a second process over any live recorded generation, even if its
  # health is bad or enrollment is absent.
  if RUNTIME.exists():
   try: live=proc_ticks(int(json.loads(RUNTIME.read_text()).get('pid',-1))) is not None
   except Exception: live=True
   if live: fail('active daemon is unhealthy; refusing duplicate start')
   RUNTIME.unlink()
  cmd=[str(ROOT/'bin/solwebd'),'--seats',str(seats)]
  subprocess.Popen(cmd,start_new_session=True,stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
  for _ in range(60):
   time.sleep(.5); current=health(legacy)
   if current['ok'] and current['seats']>=seats:return current
  return current
def main():
 p=argparse.ArgumentParser(); sub=p.add_subparsers(dest='cmd',required=True)
 e=sub.add_parser('enroll'); e.add_argument('--current-profile',action='store_true'); e.add_argument('--json',action='store_true')
 q=sub.add_parser('ensure'); q.add_argument('--json',action='store_true'); q.add_argument('--seats',type=int,default=1); q.add_argument('--legacy',action='store_true')
 h=sub.add_parser('health'); h.add_argument('--json',action='store_true'); t=sub.add_parser('token'); s=sub.add_parser('stop'); s.add_argument('--json',action='store_true')
 a=p.parse_args()
 try:
  if a.cmd=='token': print(token()); return 0
  if a.cmd=='enroll':
   if not a.current_profile or not a.json: fail('enroll requires --current-profile --json')
   emit(enroll()); return 0
  if a.cmd=='health': out=health()
  elif a.cmd=='ensure': out=ensure(a.seats, a.legacy)
  else:
   # Stop is intentionally independent of health/enrollment: an owned degraded
   # generation still holds the browser profile lock and must be drained.
   runtime=owned_runtime() if RUNTIME.exists() else None
   if runtime is not None:
    pid, ticks = int(runtime['pid']), runtime['process_start_ticks']
    req=urllib.request.Request(ENDPOINT+'/shutdown',data=b'{}',headers={'Authorization':'Bearer '+token(),'Content-Type':'application/json'},method='POST'); urllib.request.urlopen(req,timeout=3).read()
    deadline=time.monotonic()+float(os.environ.get('SOLWEBCTL_STOP_TIMEOUT','15'))
    while time.monotonic() < deadline and proc_ticks(pid) == ticks: time.sleep(.1)
    if proc_ticks(pid) == ticks: fail('daemon did not exit before stop timeout')
   out=empty('stopped')
  emit(out); return 0 if (a.cmd=='stop' or out.get('ok')) else 1
 except Exception as ex:
  if getattr(a,'json',False): emit(empty(str(ex)))
  else: print(f'solwebctl: {ex}',file=sys.stderr)
  return 1
if __name__=='__main__': sys.exit(main())
