#!/usr/bin/env python3
"""Reference browser login for Ophiolite curve applications (Python standard library)."""
import argparse
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser

DEFAULT = Path.home()/'.config/ophiolite/application.json'

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        raise ValueError('Authentication redirect refused; check the configured endpoint')


def origin(value):
    p = urllib.parse.urlsplit(value)
    if (p.scheme != 'https' and not (p.scheme == 'http' and p.hostname in ('127.0.0.1','localhost','::1'))) or p.username or p.password or p.query or p.fragment:
        raise ValueError('Use HTTPS or loopback URLs without credentials, query or fragment')
    return p.scheme, p.netloc


def same_origin(value, expected):
    if origin(value) != origin(expected): raise ValueError('Untrusted authentication endpoint')
    return value


def request(url, body=None, headers=None, form=False):
    h = dict(headers or {})
    if body is None: data = None
    elif form:
        data = urllib.parse.urlencode(body).encode(); h['Content-Type']='application/x-www-form-urlencoded'
    else:
        data = json.dumps(body, allow_nan=False).encode(); h['Content-Type']='application/json'
    req = urllib.request.Request(url, data=data, headers=h)
    try:
        with urllib.request.build_opener(NoRedirect).open(req, timeout=30) as r:
            return json.loads(r.read(2_100_001))
    except urllib.error.HTTPError as e:
        # Never print arbitrary provider responses: they can contain credentials.
        try: error=json.loads(e.read(8192)).get('error','request_failed')
        except Exception: error='request_failed'
        if form and error in ('authorization_pending','slow_down','access_denied','expired_token','invalid_grant'):
            return {'error':error}
        raise ValueError(f'Access request failed (HTTP {e.code}); check account, project permissions and grant status.') from None


def save(path, data):
    path = Path(path).expanduser()
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    if path.is_symlink(): raise ValueError('Credential file must not be a symbolic link')
    if path.exists(): check_file(path)
    fd, temporary = tempfile.mkstemp(prefix='.application-',dir=path.parent)
    try:
        with os.fdopen(fd,'w') as f:
            json.dump(data,f); f.flush(); os.fsync(f.fileno())
        os.replace(temporary,path)
    finally:
        if os.path.exists(temporary):os.unlink(temporary)


def check_file(path):
    st=path.lstat()
    if not stat.S_ISREG(st.st_mode) or st.st_mode & 0o077 or st.st_uid != os.getuid():
        raise ValueError('Credential file must be owned by you, regular and mode 0600')


class Session:
    def __init__(self, path=DEFAULT, data=None):
        self.path=Path(path).expanduser()
        self.persist = data is None
        if data is None:
            check_file(self.path); data=json.loads(self.path.read_text())
        self.data=data
        origin(data['url']); origin(data['issuer'])
        same_origin(data['token_endpoint'],data['issuer'])
        if data.get('revocation_endpoint'):same_origin(data['revocation_endpoint'],data['issuer'])

    def headers(self, url, project):
        if url.rstrip('/') != self.data['url'] or project != self.data['project']:
            raise ValueError('Saved authorization belongs to another gateway or project; log in for this project')
        if time.time() >= self.data['expires_at']-20:
            r=request(self.data['token_endpoint'], {'client_id':self.data['client_id'], 'grant_type':'refresh_token','refresh_token':self.data['refresh_token']},form=True)
            self.update(r)
            if self.persist: save(self.path,self.data)
        return {'Authorization':'Bearer '+self.data['access_token'], 'X-Ophiolite-Application-Grant':self.data.get('grant_id','')}

    def update(self,r):
        if not isinstance(r.get('access_token'),str) or not r.get('refresh_token'):
            raise ValueError('Provider session expired or revoked. Run browser login again; keep your curve output directory for retries.')
        self.data.update(access_token=r['access_token'],refresh_token=r['refresh_token'],expires_at=time.time()+float(r['expires_in']))

    def call(self, operation, body):
        return request(self.data['url']+'/api/v1/application-access/'+operation,body,self.headers(self.data['url'],self.data['project']))


def open_browser(url, disabled):
    print('Open:',url,flush=True)
    if not disabled:
        try:webbrowser.open(url)
        except Exception:pass


def login(args):
    url=args.url.rstrip('/');origin(url)
    if urllib.parse.urlsplit(url).path:raise ValueError('Use the gateway origin without a path')
    config=request(url+'/api/v1/application-access/config')
    issuer=config['issuer'];origin(issuer)
    metadata=request(issuer+'/.well-known/openid-configuration')
    if metadata.get('issuer')!=issuer:raise ValueError('Provider issuer mismatch')
    device=same_origin(metadata['device_authorization_endpoint'],issuer)
    token_endpoint=same_origin(metadata['token_endpoint'],issuer)
    revocation=same_origin(metadata['revocation_endpoint'],issuer) if metadata.get('revocation_endpoint') else None
    flow=request(device,{'client_id':config['client_id'],'scope':'openid profile email'},form=True)
    verification=flow.get('verification_uri_complete',flow.get('verification_uri',''))
    # Verification URLs may contain the public short code, never bearer credentials.
    parsed=urllib.parse.urlsplit(verification)
    same_origin(urllib.parse.urlunsplit((parsed.scheme,parsed.netloc,parsed.path,'','')),issuer)
    if parsed.username or parsed.password or parsed.fragment:raise ValueError('Invalid verification URL')
    print('Sign into your Ophiolite account. Device code:',flow['user_code'],flush=True)
    open_browser(verification,args.no_browser)
    interval=max(5,float(flow.get('interval',5)));deadline=time.monotonic()+min(600,float(flow['expires_in']))
    while time.monotonic()<deadline:
        time.sleep(interval)
        tokens=request(token_endpoint,{'client_id':config['client_id'],'device_code':flow['device_code'],'grant_type':'urn:ietf:params:oauth:grant-type:device_code'},form=True)
        if tokens.get('access_token'):break
        if tokens.get('error')=='slow_down':interval+=5
        elif tokens.get('error')!='authorization_pending':raise ValueError('Browser authorization denied or expired; run login again')
    else:raise ValueError('Browser authorization timed out')
    session=Session(args.credentials,{'url':url,'project':args.project,'issuer':issuer,'client_id':config['client_id'],'token_endpoint':token_endpoint,'revocation_endpoint':revocation})
    session.update(tokens)
    grant=session.call('request',{'project_id':args.project,'scopes':['read']+(['write'] if args.write else []),'label':args.label})
    session.data['grant_id']=grant['id']
    print('Confirm this application code in Workspace:',grant['confirmation_code'],flush=True)
    approval=grant['approval_url'];parsed=urllib.parse.urlsplit(approval)
    same_origin(urllib.parse.urlunsplit((parsed.scheme,parsed.netloc,parsed.path,'','')),url)
    open_browser(approval,args.no_browser)
    deadline=time.monotonic()+600
    while time.monotonic()<deadline:
        time.sleep(5);state=session.call('status',{'id':grant['id']})['state']
        if state=='approved':
            save(args.credentials,session.data)
            print('Application approved for project',args.project,'. Credentials saved privately; no token copying required.')
            return
        if state!='pending':raise ValueError('Application request denied or revoked')
    raise ValueError('Project consent timed out; run login again')


def main():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('--credentials',type=Path,default=DEFAULT)
    sub=p.add_subparsers(dest='command',required=True)
    l=sub.add_parser('login');l.add_argument('--url',required=True);l.add_argument('--project',required=True);l.add_argument('--write',action='store_true');l.add_argument('--label',default='Local Python curve application');l.add_argument('--no-browser',action='store_true')
    sub.add_parser('status');sub.add_parser('logout')
    args=p.parse_args()
    if args.command=='login':return login(args)
    session=Session(args.credentials)
    if args.command=='status':
        result=session.call('status',{'id':session.data['grant_id']})
        print(result['state'],result['project_id'],','.join(result['scopes']));return
    failures=[]
    try:session.call('revoke',{'id':session.data['grant_id']})
    except (ValueError,OSError):failures.append('Workspace grant revocation unconfirmed; revoke it in Workspace.')
    if session.data.get('revocation_endpoint'):
        try:
            # Revocation success may have an empty body.
            req=urllib.request.Request(session.data['revocation_endpoint'],data=urllib.parse.urlencode({'client_id':session.data['client_id'],'token':session.data['refresh_token'],'token_type_hint':'refresh_token'}).encode())
            with urllib.request.build_opener(NoRedirect).open(req,timeout=30):pass
        except (ValueError,OSError):failures.append('Provider revocation unconfirmed; end the application session at your provider.')
    else:failures.append('Provider offers no refresh-token revocation endpoint.')
    args.credentials.expanduser().unlink()
    print('Local application credentials removed.')
    if failures:raise ValueError(' '.join(failures))

if __name__=='__main__':
    try:main()
    except (ValueError,OSError,KeyError) as e:
        print(str(e) if isinstance(e,ValueError) else 'Application access unavailable; check configuration and retry.',file=sys.stderr);sys.exit(1)
