#!/usr/bin/env python3
"""Explicit trace upload. No automatic discovery; no secret values in logs.
An accepted batch must match server batch id/hash; ambiguous responses stay pending.
Local spooling survives process exit, not machine/disk disappearance.
"""
import argparse, hashlib, json, os, pathlib, re, tempfile, urllib.request, urllib.error, urllib.parse

# One deployment serves both hostnames and one account store. A credential stays bound to the origin it was approved on.
ORIGINS=('https://supatrace.tech','https://supatrace.dev')
DEFAULT_ORIGIN=ORIGINS[0]

def canonical(value):
    return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()

def atomic_write(path, data):
    path=pathlib.Path(path); path.parent.mkdir(parents=True, exist_ok=True)
    fd, name=tempfile.mkstemp(prefix='.pending-',dir=path.parent)
    try:
        os.fchmod(fd,0o600)
        with os.fdopen(fd,'wb') as f: f.write(data); f.flush(); os.fsync(f.fileno())
        os.replace(name,path)
        directory=os.open(path.parent,os.O_RDONLY)
        try: os.fsync(directory)
        finally: os.close(directory)
    finally:
        if os.path.exists(name): os.unlink(name)

def stage(source, spool):
    raw=pathlib.Path(source).read_bytes()
    if len(raw)>524288: raise ValueError('Batch exceeds 512 KiB')
    batch=json.loads(raw)
    if not isinstance(batch,dict) or not isinstance(batch.get('batch_id'),str): raise ValueError('Missing batch id')
    digest=hashlib.sha256(canonical(batch)).hexdigest()
    path=pathlib.Path(spool)/(digest+'.json')
    atomic_write(path,canonical(batch))
    return path

def upload_pending(path, endpoint, token, transport=None, expected_project=None):
    if not endpoint.startswith('https://'): raise ValueError('HTTPS endpoint required')
    if expected_project is not None:
        if not isinstance(expected_project,str) or not re.fullmatch(r'proj_[a-f0-9]{32}',expected_project):raise ValueError('Invalid project id')
        url=urllib.parse.urlsplit(endpoint);query=urllib.parse.parse_qs(url.query)
        if 'project' in query and query['project']!=[expected_project]:raise ValueError('Conflicting project target')
        query['project']=[expected_project];endpoint=urllib.parse.urlunsplit(url._replace(query=urllib.parse.urlencode(query,doseq=True)))
    body=pathlib.Path(path).read_bytes(); batch=json.loads(body)
    request=urllib.request.Request(endpoint,body,headers={'Authorization':'Bearer '+token,'X-Supatrace-Key':token,'Content-Type':'application/json','x-amz-content-sha256':hashlib.sha256(body).hexdigest()},method='POST')
    # Refuse redirects: never forward credentials or acknowledge a different endpoint.
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self,*args,**kwargs): return None
    open_request=transport or urllib.request.build_opener(NoRedirect).open
    try:
        with open_request(request,timeout=30) as response:
            if response.status not in (200,201): return False
            data=response.read(8193)
            if len(data)>8192: return False
            ack=json.loads(data)
            if ack.get('accepted') is not True or ack.get('batch_id')!=batch['batch_id']: return False
            if ack.get('request_hash')!=hashlib.sha256(body).hexdigest(): return False
            if expected_project is not None and ack.get('project_id')!=expected_project:return False
        pathlib.Path(path).unlink()
        return True
    except (OSError,urllib.error.URLError,ValueError):
        return False

def main():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('file');p.add_argument('--config');p.add_argument('--endpoint');p.add_argument('--spool',default=str(pathlib.Path.home()/'.supatrace'/'pending'))
    args=p.parse_args();token=os.environ.get('SUPATRACE_TOKEN');project=None
    if args.config:
        config=json.loads(pathlib.Path(args.config).read_text())
        if not config.get('approved') or config.get('origin') not in ORIGINS:p.error('Config is not approved for Supatrace')
        token=config.get('write_key')
        project=config.get('project_id')
        bound=config['origin']
        if not args.endpoint:args.endpoint=bound+'/api/batches'
        destination=urllib.parse.urlsplit(args.endpoint)
        if destination.scheme!='https' or destination.hostname!=urllib.parse.urlsplit(bound).hostname or destination.port not in (None,443) or destination.path!='/api/batches':p.error('Config credential is bound to the Supatrace batches endpoint')
    if not args.endpoint:args.endpoint=DEFAULT_ORIGIN+'/api/batches'
    if not token: p.error('Set SUPATRACE_TOKEN for this upload session')
    pending=stage(args.file,args.spool)
    if upload_pending(pending,args.endpoint,token,expected_project=project): print('Accepted by server.')
    else: print('Not acknowledged. Local batch retained; rerun with the same file to retry.');raise SystemExit(1)
if __name__=='__main__':main()
