#!/usr/bin/env python3
"""Add Unbound to OpenCode 1/2 without touching credentials or other providers."""
import argparse,contextlib,getpass,json,os,pathlib,re,shlex,shutil,subprocess,sys,tempfile,urllib.request,urllib.error

def jsonc(text):
    out=[];i=0;quoted=False;escaped=False
    while i<len(text):
        c=text[i]
        if quoted:
            out.append(c)
            if escaped:escaped=False
            elif c=='\\':escaped=True
            elif c=='"':quoted=False
            i+=1;continue
        if c=='"':quoted=True;out.append(c);i+=1;continue
        if text[i:i+2]=='//':
            end=text.find('\n',i+2);i=len(text) if end<0 else end;continue
        if text[i:i+2]=='/*':
            end=text.find('*/',i+2)
            if end<0:raise ValueError('Unterminated JSONC comment')
            out.append(' ');i=end+2;continue
        out.append(c);i+=1
    text=''.join(out);out=[];quoted=False;escaped=False
    for i,c in enumerate(text):
        if quoted:
            out.append(c)
            if escaped:escaped=False
            elif c=='\\':escaped=True
            elif c=='"':quoted=False
        elif c=='"':quoted=True;out.append(c)
        elif c==',' and text[i+1:].lstrip().startswith(('}',']')):continue
        else:out.append(c)
    return json.loads(''.join(out))

def merge(config,snippet,major):
    if not isinstance(config,dict):raise ValueError('OpenCode config must be an object')
    key='provider' if major==1 else 'providers'
    if key in config and not isinstance(config[key],dict):raise ValueError('Invalid provider configuration; nothing changed')
    config.setdefault(key,{})['unbound']=snippet[key]['unbound']
    if major==1:
        if isinstance(config.get('enabled_providers'),list) and 'unbound' not in config['enabled_providers']:config['enabled_providers'].append('unbound')
        if isinstance(config.get('disabled_providers'),list):config['disabled_providers']=[p for p in config['disabled_providers'] if p!='unbound']
    config.setdefault('$schema','https://opencode.ai/config.json')
    return config

def install(folder,snippet,major):
    folder=pathlib.Path(folder).expanduser();folder.mkdir(parents=True,exist_ok=True)
    candidates=[folder/'opencode.json',folder/'opencode.jsonc'];existing=[p for p in candidates if p.exists()]
    # If both exist update the higher-precedence JSONC file, preserving the JSON file.
    target=(existing[-1] if existing else candidates[0]).resolve()
    original=target.read_text(encoding='utf-8') if target.exists() else '{}'
    config=jsonc(original);updated=merge(config,snippet,major)
    key='provider' if major==1 else 'providers'
    if target.exists():
        fd,backup=tempfile.mkstemp(prefix=target.name+'.unbound-backup-',dir=target.parent)
        with os.fdopen(fd,'w',encoding='utf-8') as output:output.write(original)
        os.chmod(backup,0o600);print('Original settings backed up to '+backup)
    fd,name=tempfile.mkstemp(prefix=target.name+'.unbound-',dir=target.parent)
    try:
        if hasattr(os,'fchmod'):os.fchmod(fd,0o600)
        with os.fdopen(fd,'w',encoding='utf-8') as output:json.dump(updated,output,indent=2,ensure_ascii=False);output.write('\n')
        os.replace(name,target)
    finally:
        if os.path.exists(name):os.unlink(name)
    return target

BASE='https://unbound-me.com'
CLIENTS={'codex':('Codex','@openai/codex'),'claude':('Claude Code','@anthropic-ai/claude-code'),'opencode':('OpenCode','opencode-ai')}

def atomic_write(path,text,mode=0o600):
    path=pathlib.Path(path);path.parent.mkdir(parents=True,exist_ok=True)
    fd,name=tempfile.mkstemp(prefix='.'+path.name+'-',dir=path.parent)
    try:
        if hasattr(os,'fchmod'):os.fchmod(fd,mode)
        with os.fdopen(fd,'w',encoding='utf-8') as f:f.write(text)
        os.replace(name,path)
    finally:
        if os.path.exists(name):os.unlink(name)
    return path

def state_folder():
    if os.name=='nt':return pathlib.Path(os.environ.get('LOCALAPPDATA') or pathlib.Path.home()/'AppData'/'Local')/'Unbound'
    return pathlib.Path(os.environ.get('XDG_CONFIG_HOME') or pathlib.Path.home()/'.config')/'unbound'

def fetch_json(url,key=None):
    headers={'User-Agent':'unbound-setup/2.0','Accept':'application/json'}
    if key:headers['Authorization']='Bearer '+key
    request=urllib.request.Request(url,headers=headers)
    try:
        with urllib.request.urlopen(request,timeout=25) as response:return json.load(response)
    except urllib.error.HTTPError as e:
        if e.code in (401,403):raise ValueError('API key was rejected. Check the key in Dashboard > API keys.') from None
        raise ValueError('Unbound returned HTTP '+str(e.code)+'. No model request was sent.') from None

def choose(title,items,default=0):
    print('\n'+title)
    for i,(_,label) in enumerate(items):print('  '+str(i+1)+'. '+label+(' [default]' if i==default else ''))
    while True:
        value=input('Choose [1-'+str(len(items))+']: ').strip()
        if not value:return items[default][0]
        if value.isdigit() and 1<=int(value)<=len(items):return items[int(value)-1][0]
        for code,_ in items:
            if value.lower()==code.lower():return code
        print('Enter a listed number or name.')

def detect_major(executable):
    result=subprocess.run([executable,'--version'],capture_output=True,text=True,timeout=15)
    match=re.search(r'\bv?(\d+)\.\d+',result.stdout)
    if not match:raise ValueError('Could not detect OpenCode version. Use --major 1 or --major 2.')
    return 1 if int(match[1])<2 else 2

def launch_command(client,executable,model,key,extra=(),opencode_major=None):
    env=os.environ.copy();env['UNBOUND_API_KEY']=key
    if client=='codex':
        command=[executable,'-c','model_provider="unbound"','-c','model_providers.unbound.name="Unbound"','-c','model_providers.unbound.base_url="'+BASE+'/v1"','-c','model_providers.unbound.env_key="UNBOUND_API_KEY"','-c','model_providers.unbound.wire_api="responses"','-c','model_providers.unbound.requires_openai_auth=false','--model',model]
    elif client=='claude':
        for name in ('ANTHROPIC_API_KEY','CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_CUSTOM_HEADERS','CLAUDE_CODE_USE_BEDROCK','CLAUDE_CODE_USE_VERTEX','CLAUDE_CODE_USE_FOUNDRY'):env.pop(name,None)
        env.update(ANTHROPIC_BASE_URL=BASE,ANTHROPIC_AUTH_TOKEN=key,ANTHROPIC_MODEL=model,ANTHROPIC_DEFAULT_OPUS_MODEL=model,ANTHROPIC_DEFAULT_SONNET_MODEL=model,ANTHROPIC_DEFAULT_HAIKU_MODEL=model,CLAUDE_CODE_SUBAGENT_MODEL=model)
        command=[executable,'--model',model]
    else:
        major=opencode_major or detect_major(executable)
        # V2 reads the model from config and rejects the V1 --model flag.
        # A private server inherits this launcher's key instead of a stale shared service environment.
        command=[executable,'--standalone'] if major>=2 else [executable,'--model','unbound/'+model]
    return command+list(extra),env

def save_setup(folder,client,model,key):
    folder=pathlib.Path(folder).expanduser();folder.mkdir(parents=True,exist_ok=True)
    if os.name!='nt':os.chmod(folder,0o700)
    path=folder/'clients.json';config=json.loads(path.read_text()) if path.exists() else {}
    config.setdefault('clients',{})[client]={'model':model,'key':key};config['default']=client
    atomic_write(path,json.dumps(config,indent=2)+'\n')
    source=pathlib.Path(__file__)
    if source.is_file():script=source.read_text(encoding='utf-8')
    else:
        with urllib.request.urlopen(urllib.request.Request(BASE+'/opencode/install.py',headers={'User-Agent':'unbound-setup/2.0'}),timeout=25) as response:script=response.read(2*1024*1024).decode('utf-8')
    runner=atomic_write(folder/'launch.py',script,0o700)
    # Launcher contains paths only; credentials never appear in command arguments.
    if os.name!='nt':
        launcher=pathlib.Path.home()/'.local'/'bin'/'unbound'
        atomic_write(launcher,'#!/bin/sh\nexec '+shlex.quote(sys.executable)+' '+shlex.quote(str(runner))+' --state-dir '+shlex.quote(str(folder))+' --launch "$@"\n',0o700)
        return str(launcher)
    return subprocess.list2cmdline([sys.executable,str(runner),'--state-dir',str(folder),'--launch'])

def legacy_install(args):
    executable=shutil.which('opencode');major=int(args.major) if args.major else detect_major(executable) if executable else 1
    snippet=json.loads(pathlib.Path(args.config_file).read_text()) if args.config_file else fetch_json(BASE+'/opencode/config.json?version='+str(major))
    prop='provider' if major==1 else 'providers';provider=snippet.get(prop,{}).get('unbound',{})
    if not provider.get('models') or (provider.get('options') or provider.get('settings') or {}).get('baseURL')!=BASE+'/v1':raise ValueError('Invalid Unbound provider configuration.')
    target=install(args.config_dir or pathlib.Path(os.environ.get('XDG_CONFIG_HOME') or pathlib.Path.home()/'.config')/'opencode',snippet,major)
    print('Unbound provider installed: '+str(target));return 0

@contextlib.contextmanager
def terminal():
    if sys.stdin.isatty():yield;return
    try:tty=open('CONIN$' if os.name=='nt' else '/dev/tty','r')
    except OSError:raise ValueError('An interactive terminal is required. Download install.py and run it with Python in your terminal.') from None
    original=sys.stdin
    try:sys.stdin=tty;yield
    finally:sys.stdin=original;tty.close()

def mask_key(key):
    return key[:3]+'*'*(len(key)-6)+key[-3:] if len(key)>6 else '*'*len(key)

def masked_input(read_character,prompt):
    sys.stderr.write(prompt);sys.stderr.flush();characters=[]
    while True:
        c=read_character()
        if c in ('\r','\n'):
            if characters:sys.stderr.write('  Key pasted.');sys.stderr.flush()
            return ''.join(characters)
        if c=='\x03':raise KeyboardInterrupt
        if not c or c=='\x04':raise ValueError('Terminal input closed; setup cancelled.')
        if c in ('\b','\x7f'):
            if characters:characters.pop();sys.stderr.write('\b \b');sys.stderr.flush()
        elif c=='\x15':
            sys.stderr.write('\b \b'*len(characters));sys.stderr.flush();characters=[]
        elif c.isprintable():
            if len(characters)>=4096:raise ValueError('API key is too long.')
            characters.append(c);sys.stderr.write('*');sys.stderr.flush()

def read_hidden_key(prompt='Unbound API key: '):
    if os.name=='nt':
        import msvcrt
        def character():
            c=msvcrt.getwch()
            if c in ('\x00','\xe0'):msvcrt.getwch();return '\x00'
            return c
        try:return masked_input(character,prompt)
        finally:sys.stderr.write('\n');sys.stderr.flush()
    import termios
    fd=sys.stdin.fileno()
    try:
        original=termios.tcgetattr(fd);hidden=original[:];hidden[6]=original[6][:]
        hidden[3]&=~(termios.ECHO|termios.ECHONL|termios.ICANON)
        hidden[6][termios.VMIN]=1;hidden[6][termios.VTIME]=0
        termios.tcsetattr(fd,termios.TCSANOW,hidden)
    except (OSError,termios.error):raise ValueError('Cannot disable terminal echo. Download the installer and run python3 install.py directly in your terminal.') from None
    try:return masked_input(lambda:sys.stdin.read(1),prompt)
    finally:
        termios.tcsetattr(fd,termios.TCSADRAIN,original)
        sys.stderr.write('\n');sys.stderr.flush()

def main():
    parser=argparse.ArgumentParser(description='Interactive Unbound setup for Codex, Claude Code and OpenCode.')
    parser.add_argument('--client',choices=list(CLIENTS));parser.add_argument('--major',choices=['1','2']);parser.add_argument('--no-login',action='store_true',help='Legacy configuration-only mode; no credential prompt or launch')
    parser.add_argument('--no-launch',action='store_true');parser.add_argument('--config-dir');parser.add_argument('--config-file');parser.add_argument('--state-dir');parser.add_argument('--launch',nargs='?',const='default',choices=['default']+list(CLIENTS));args,extra=parser.parse_known_args()
    if args.no_login:return legacy_install(args)
    folder=pathlib.Path(args.state_dir).expanduser() if args.state_dir else state_folder()
    if args.launch:
        path=folder/'clients.json'
        if not path.exists():raise ValueError('Run the installer first.')
        settings=json.loads(path.read_text());client=settings['default'] if args.launch=='default' else args.launch
        saved=settings.get('clients',{}).get(client)
        if not saved:raise ValueError('This client is not configured. Run setup again and select it.')
        executable=shutil.which(client)
        if not executable:raise ValueError(CLIENTS[client][0]+' is not installed or is missing from PATH.')
        command,env=launch_command(client,executable,saved['model'],saved['key'],extra)
        return subprocess.run(command,env=env).returncode
    if extra:parser.error('Unrecognized setup arguments: '+' '.join(extra))
    with terminal():
        print('\nUNBOUND / Coding setup\nChoose your coding app, connect your key and pick its default model.')
        client=args.client or choose('1 / Coding app',[(name,label) for name,(label,_) in CLIENTS.items()],2)
        executable=shutil.which(client)
        if not executable:
            npm=shutil.which('npm')
            if not npm:raise ValueError('Install '+CLIENTS[client][0]+' or Node.js/npm first, then run setup again.')
            if input('Install '+CLIENTS[client][0]+' with npm? [Y/n] ').strip().lower() not in ('','y','yes'):return 0
            result=subprocess.run([npm,'install','--global',CLIENTS[client][1]])
            if result.returncode:return result.returncode
            executable=shutil.which(client)
            if not executable:raise ValueError('Installation completed but the app is not on PATH. Reopen your terminal and rerun setup.')
        print('\n2 / API key — paste your Unbound API key. Characters appear as *; press Enter to confirm.')
        key=read_hidden_key().strip()
        if not key or any(c.isspace() for c in key):raise ValueError('Enter a non-empty API key without whitespace.')
        inventory=fetch_json(BASE+'/v1/models',key).get('data',[])
        models=[(m['id'],m.get('name') or m['id']) for m in inventory if isinstance(m,dict) and isinstance(m.get('id'),str) and re.fullmatch(r'[A-Za-z0-9_.:/-]{1,200}',m['id'])]
        if not models:raise ValueError('This API key has no available models. Check its model permissions.')
        print('API key: '+mask_key(key))
        print('Key accepted. No paid generation was sent.')
        model=choose('3 / Default model',models)
        if client=='opencode':
            major=int(args.major) if args.major else detect_major(executable)
            snippet=json.loads(pathlib.Path(args.config_file).read_text()) if args.config_file else fetch_json(BASE+'/opencode/config.json?version='+str(major))
            prop='provider' if major==1 else 'providers';provider=snippet.get(prop,{}).get('unbound',{})
            if (provider.get('options') or provider.get('settings') or {}).get('baseURL')!=BASE+'/v1':raise ValueError('Invalid Unbound endpoint in provider configuration.')
            provider['models']={k:v for k,v in provider.get('models',{}).items() if k in {m[0] for m in models}}
            if model not in provider['models']:raise ValueError('Selected model is missing from the provider catalog.')
            provider.setdefault('options' if major==1 else 'settings',{})['apiKey']='{env:UNBOUND_API_KEY}'
            destination=args.config_dir or pathlib.Path(os.environ.get('XDG_CONFIG_HOME') or pathlib.Path.home()/'.config')/'opencode'
            snippet['model']='unbound/'+model
            target=install(destination,snippet,major)
            # Explicitly selected default; install() preserves defaults for the legacy workflow.
            config=json.loads(target.read_text());config['model']=snippet['model'];atomic_write(target,json.dumps(config,indent=2)+'\n')
        launcher=save_setup(folder,client,model,key)
        print('\n4 / Ready — '+CLIENTS[client][0]+' · '+model)
        print('Key saved privately on this computer. Start again: '+launcher+' '+client)
        if args.no_launch:return 0
        command,env=launch_command(client,executable,model,key,opencode_major=major if client=='opencode' else None)
        print('Starting '+CLIENTS[client][0]+'…',flush=True)
        return subprocess.run(command,env=env,stdin=sys.stdin).returncode

if __name__=='__main__':
    try:sys.exit(main())
    except KeyboardInterrupt:print('\nSetup cancelled.');sys.exit(130)
    except (OSError,ValueError,subprocess.SubprocessError,getpass.GetPassWarning) as error:print('Unbound setup: '+str(error),file=sys.stderr);sys.exit(1)
