"""Local operator API and queue workers. Bind to loopback only."""
import argparse, hmac, json, os, secrets, sqlite3, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
from delivery_queue import Queue, Problem, DestinationPolicy
from receiver import SECRET

HERE=Path(__file__).resolve().parent
DEMO_TOKEN='demo-admin-token'

def make_server(queue,token,port=9080):
    class Handler(BaseHTTPRequestHandler):
        def log_message(self,*args): pass
        def setup(self):
            super().setup();self.connection.settimeout(15)
        def local_request(self):
            host=self.headers.get('Host','')
            if host not in (f'127.0.0.1:{self.server.server_port}',f'localhost:{self.server.server_port}'):
                self.reply(403,{'error':'Localhost access only'});return False
            origin=self.headers.get('Origin')
            if origin and origin!='http://'+host:
                self.reply(403,{'error':'Cross-origin request rejected'});return False
            return True
        def reply(self,status,payload,content_type='application/json'):
            raw=json.dumps(payload,allow_nan=False).encode() if content_type=='application/json' else payload
            self.send_response(status);self.send_header('Content-Type',content_type);self.send_header('Content-Length',str(len(raw)))
            self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff')
            self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
            self.end_headers();self.wfile.write(raw)
        def authorized(self):
            return hmac.compare_digest(self.headers.get('Authorization',''),'Bearer '+token)
        def body(self):
            if self.headers.get('Content-Type','').split(';')[0]!='application/json': raise Problem('Use application/json',415)
            try: length=int(self.headers.get('Content-Length','0'))
            except ValueError: raise Problem('Invalid body length')
            if not 0<length<=100000: raise Problem('Body must be 1-100000 bytes',413)
            try: value=json.loads(self.rfile.read(length),parse_constant=lambda x: (_ for _ in ()).throw(ValueError()))
            except (ValueError,UnicodeError): raise Problem('Invalid JSON')
            if not isinstance(value,dict): raise Problem('JSON object required')
            return value
        def do_GET(self):
            if not self.local_request():return
            path=urlsplit(self.path).path
            if path in ('/','/app.js','/style.css'):
                filename={'/':'index.html','/app.js':'app.js','/style.css':'style.css'}[path]
                kind={'/':'text/html; charset=utf-8','/app.js':'text/javascript; charset=utf-8','/style.css':'text/css; charset=utf-8'}[path]
                self.reply(200,(HERE/'static'/filename).read_bytes(),kind);return
            if not self.authorized(): self.reply(401,{'error':'Administrator token required'});return
            if path=='/api/state': self.reply(200,queue.snapshot())
            else: self.reply(404,{'error':'Not found'})
        def do_POST(self):
            if not self.local_request():return
            if not self.authorized(): self.reply(401,{'error':'Administrator token required'});return
            try:
                data=self.body();path=urlsplit(self.path).path
                if path=='/api/events':
                    result=queue.accept(data.get('id'),data.get('type'),data.get('data'),data.get('endpoints'))
                    self.reply(200 if result['duplicate'] else 201,result)
                elif path=='/api/pause': queue.pause(data.get('endpoint'),data.get('paused'));self.reply(200,{'ok':True})
                elif path=='/api/replay':
                    if type(data.get('job')) is not int: raise Problem('job must be an integer')
                    queue.replay(data['job']);self.reply(200,{'ok':True})
                else: self.reply(404,{'error':'Not found'})
            except Problem as e:self.reply(e.status,{'error':str(e)})
            except (KeyError,TypeError):self.reply(400,{'error':'Invalid request fields'})
            except sqlite3.Error:self.reply(503,{'error':'Storage unavailable; retry using the same event identity'})
    return ThreadingHTTPServer(('127.0.0.1',port),Handler)

def setup(path,demo=False,receiver_port=9081,settings=None):
    if demo:
        origin=f'http://127.0.0.1:{receiver_port}'
        policy=DestinationPolicy(test_origins=[origin])
        endpoints=[{'id':'normal','url':origin+'/ok','secret':SECRET},
                   {'id':'response-loss','url':origin+'/drop-once','secret':SECRET},
                   {'id':'rejected','url':origin+'/reject','secret':SECRET}]
    else:
        config=json.loads(Path(settings).read_text()) if settings else {'approved_hosts':[],'endpoints':[]}
        policy=DestinationPolicy(approved_hosts=config.get('approved_hosts',[]));endpoints=config.get('endpoints',[])
    queue=Queue(path,policy)
    existing={x['id'] for x in queue.snapshot()['endpoints']}
    for endpoint in endpoints:
        if endpoint['id'] not in existing:queue.add_endpoint(endpoint_id=endpoint['id'],**{k:v for k,v in endpoint.items() if k!='id'})
    return queue

def worker(queue,stop):
    while not stop.is_set():
        try:
            if not queue.work_once():stop.wait(.1)
        except Exception as exc:
            # Deliberately omit payloads, URLs, signing secrets and tracebacks from operational logs.
            print('Worker error:',type(exc).__name__,flush=True);stop.wait(.5)

def main():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('--db',default='queue.sqlite');p.add_argument('--port',type=int,default=9080)
    p.add_argument('--demo',action='store_true');p.add_argument('--receiver-port',type=int,default=9081);p.add_argument('--settings');p.add_argument('--workers',type=int,default=3)
    p.add_argument('--retention-days',type=int,default=7);args=p.parse_args()
    if not 0<=args.workers<=16 or args.retention_days<1:p.error('workers must be 0-16 and retention-days positive')
    token=DEMO_TOKEN if args.demo else os.environ.get('WEBHOOK_TOKEN')
    if not token or len(token)<16:p.error('Set WEBHOOK_TOKEN to at least 16 characters, or use --demo for synthetic local data')
    queue=setup(args.db,args.demo,args.receiver_port,args.settings)
    queue.purge(time.time()-args.retention_days*86400)
    stop=threading.Event();threads=[threading.Thread(target=worker,args=(queue,stop),daemon=True) for _ in range(args.workers)]
    for thread in threads:thread.start()
    # Retention maintenance runs periodically, not just on restart.
    def retain():
        while not stop.wait(60):queue.purge(time.time()-args.retention_days*86400)
    threading.Thread(target=retain,daemon=True).start()
    server=make_server(queue,token,args.port)
    print(f'Operator UI: http://127.0.0.1:{server.server_port}/')
    if args.demo:print('Demo token:',DEMO_TOKEN,'| Receiver: python receiver.py')
    try:server.serve_forever()
    except KeyboardInterrupt:pass
    finally:
        stop.set();server.server_close()
        for thread in threads:thread.join(timeout=35)

if __name__=='__main__':main()
