"""LearnAI P08: bounded, loopback-only teaching service; Python standard library.
Save beside model-component.py and trusted bundle JSON files. Run --help.
Not a production server: single connection at a time, no TLS/accounts/persistent DB.
Tokens authorize local API access. Never publish the port or share the token file.
Model JSON is inert. Only the supplied adjacent Python runtime is imported as code.
"""
import argparse
import hmac
import hashlib
import importlib.util
import json
import math
import re
import secrets
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib import request, error

RUNTIME_PATH=Path(__file__).resolve().with_name('model-component.py')
spec=importlib.util.spec_from_file_location('component',RUNTIME_PATH)
component=importlib.util.module_from_spec(spec)
spec.loader.exec_module(component)


def strict_json(raw):
    def pairs(items):
        result={}
        for key,value in items:
            if key in result:raise ValueError('Duplicate JSON key')
            result[key]=value
        return result
    def constant(_):raise ValueError('Non-finite JSON value')
    return json.loads(raw,object_pairs_hook=pairs,parse_constant=constant)


def load_bundle(path):
    with Path(path).open('rb') as handle:raw=handle.read(16001)
    if len(raw)>16000:raise ValueError('Bundle exceeds 16,000 bytes')
    bundle=strict_json(raw)
    return {'model':component.load_component(bundle),'sha256':hashlib.sha256(raw).hexdigest(),'modelSha256':bundle['model']['sha256'],'preprocessSha256':bundle['preprocess']['sha256']}


def exact(value,keys):
    if not isinstance(value,dict) or set(value)!=set(keys):raise ValueError('Unexpected fields')


def request_id(value):
    if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z0-9._-]{1,64}',value):raise ValueError('Invalid request id')
    return value


class Service:
    def __init__(self,stable,candidate):
        self.bundles={'stable':load_bundle(stable),'candidate':load_bundle(candidate)}
        self.active='stable';self.paused=False;self.revision=0;self.probe=None
        self.records={};self.events=[];self.replay_count=0

    def manifest(self):
        return {'format':'learnai-service-v1','runtimeSha256':hashlib.sha256(RUNTIME_PATH.read_bytes()).hexdigest(),'serviceSha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'active':self.active,'paused':self.paused,'revision':self.revision,'bundles':{key:{k:v for k,v in bundle.items() if k!='model'} for key,bundle in self.bundles.items()}}

    def metrics(self):
        groups=[]
        for release,group in [(r,g) for r in ['stable','candidate'] for g in ['complete','missing']]:
            rows=[r for r in self.records.values() if r['group']==group and r['response']['release']==release]
            labelled=[r for r in rows if r['label'] is not None]
            wrong=sum(r['label']!=r['response']['label'] for r in labelled)
            groups.append({'release':release,'bundleSha256':self.bundles[release]['sha256'],'group':group,'predictions':len(rows),'labels':len(labelled),'errors':wrong,'errorRate':wrong/len(labelled) if labelled else None})
        outcomes={str(code):sum(e['endpoint']=='/predict' and e['status']==code for e in self.events) for code in [200,400,409,429,503]}
        return {'groups':groups,'requestStatuses':outcomes,'replayedIds':self.replay_count,'retainedPredictions':len(self.records),'limit':40,'scope':'This process only. Caller-supplied labels are unverified, selected feedback, not independent evaluation.'}

    def call(self,path,body):
        if path=='/predict':
            exact(body,['id','request']);rid=request_id(body['id']);fingerprint=json.dumps(body['request'],sort_keys=True,separators=(',',':'),allow_nan=False)
            old=self.records.get(rid)
            if old:
                if old['fingerprint']!=fingerprint:return 409,{'error':'Request id already used for different input'}
                self.replay_count+=1
                return 200,{**old['response'],'replayed':True}
            if self.paused:return 503,{'error':'Delivery paused; request not counted as a prediction'}
            if len(self.records)>=40:return 429,{'error':'Forty retained predictions reached; export evidence and restart'}
            result=component.predict(self.bundles[self.active]['model'],body['request'])
            response={'schema':'learnai-service-result-v1','id':rid,'release':self.active,'bundleSha256':self.bundles[self.active]['sha256'],'revision':self.revision,**result,'replayed':False}
            self.records[rid]={'fingerprint':fingerprint,'response':response,'label':None,'group':'missing' if result['missing'] else 'complete'}
            return 200,response
        if path=='/feedback':
            exact(body,['id','label']);rid=request_id(body['id'])
            if body['label'] not in ['other','Iris-virginica']:raise ValueError('Unsupported label')
            if rid not in self.records:return 404,{'error':'Unknown request id'}
            row=self.records[rid]
            if row['label'] is not None:return 409,{'error':'Feedback already recorded; original retained'}
            row['label']=body['label'];return 200,{'id':rid,'accepted':True,'warning':'Caller-supplied label, not verified ground truth'}
        if path=='/probe':
            exact(body,['target','requests'])
            if body['target'] not in self.bundles or not isinstance(body['requests'],list) or not 2<=len(body['requests'])<=20:raise ValueError('Probe needs a known target and 2–20 requests')
            rows=[]
            for r in body['requests']:
                reference=component.predict(self.bundles['stable']['model'],r);actual=component.predict(self.bundles[body['target']]['model'],r)
                delta=abs(actual['probability']-reference['probability'])
                rows.append({'difference':delta,'pass':delta<=1e-6,'group':'missing' if reference['missing'] else 'complete'})
            both={r['group'] for r in rows}=={'complete','missing'}
            self.probe={'target':body['target'],'revision':self.revision,'rows':rows,'pass':both and all(r['pass'] for r in rows),'reference':'Loaded stable component; compatibility comparison, not an independent accuracy oracle'}
            return 200,self.probe.copy()
        if path=='/admin':
            exact(body,['action']);action=body['action']
            if action not in ['pause','rollback','resume','promote']:raise ValueError('Unknown action')
            target='candidate' if action=='promote' else self.active
            if action in ['resume','promote']:
                if not self.probe or self.probe['target']!=target or self.probe['revision']!=self.revision or not self.probe['pass']:return 409,{'error':'Fresh passing target/configuration probe with both input groups required'}
            if action=='pause':self.paused=True
            elif action=='rollback':self.active='stable'
            elif action=='resume':self.paused=False
            else:self.active='candidate'
            self.revision+=1;self.probe=None
            return 200,{'action':action,'active':self.active,'paused':self.paused,'revision':self.revision,'warning':'Future new requests only; cached receipts and earlier consequences remain unchanged'}
        return 404,{'error':'Unknown endpoint'}


class TeachingServer(HTTPServer):
    def __init__(self,port,service,token,log=None):
        super().__init__(('127.0.0.1',port),Handler)
        self.service=service;self.token=token;self.log=log;self.connections=0;self.timeout=1
    def get_request(self):
        sock,address=super().get_request();sock.settimeout(2);self.connections+=1;return sock,address


class Handler(BaseHTTPRequestHandler):
    protocol_version='HTTP/1.0'
    def log_message(self,*args):pass
    def respond(self,status,data):
        raw=json.dumps(data,allow_nan=False,separators=(',',':')).encode()
        self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(raw)));self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(raw)
    def authorized(self):
        expected=f'127.0.0.1:{self.server.server_port}'
        if len(self.headers.get_all('Host',[]))!=1 or self.headers.get('Host')!=expected or self.headers.get('Origin') is not None:
            self.respond(403,{'error':'Only explicit loopback clients without browser Origin are allowed'});return False
        supplied=self.headers.get('Authorization','')
        if len(self.headers.get_all('Authorization',[]))!=1 or not hmac.compare_digest(supplied.encode('utf-8'),('Bearer '+self.server.token).encode('utf-8')):
            self.respond(401,{'error':'Valid local token required'});return False
        return True
    def do_GET(self):
        if not self.authorized():return
        if self.path=='/health':self.respond(200,self.server.service.manifest())
        elif self.path=='/metrics':self.respond(200,self.server.service.metrics())
        else:self.respond(404,{'error':'Unknown endpoint'})
    def do_POST(self):
        if not self.authorized():return
        started=time.perf_counter()
        try:
            length=self.headers.get('Content-Length','')
            if len(self.headers.get_all('Content-Length',[]))!=1:raise ValueError('Exactly one Content-Length required')
            if self.headers.get('Transfer-Encoding') or not length.isdigit() or not 1<=int(length)<=16000 or self.headers.get('Content-Type')!='application/json':raise ValueError('Require application/json and Content-Length 1–16000; no transfer encoding')
            raw=self.rfile.read(int(length))
            if len(raw)!=int(length):raise ValueError('Incomplete body')
            body=strict_json(raw);status,result=self.server.service.call(self.path,body)
        except TimeoutError:status,result=408,{'error':'Request body timeout'}
        except (ValueError,TypeError,KeyError,OverflowError,RecursionError,UnicodeError) as exc:status,result=400,{'error':str(exc)[:160]}
        elapsed=(time.perf_counter()-started)*1000
        event={'sequence':len(self.server.service.events)+1,'endpoint':self.path[:80],'status':status,'serverProcessingMs':elapsed,'revision':self.server.service.revision}
        self.server.service.events.append(event)
        if self.server.log:self.server.log.write(json.dumps(event)+'\n');self.server.log.flush()
        self.respond(status,result)


def fresh_file(path):
    # Exclusive creation prevents overwriting prior reports or a preexisting token.
    return Path(path).open('x',encoding='utf-8')


def main():
    parser=argparse.ArgumentParser(description=__doc__);sub=parser.add_subparsers(dest='mode',required=True)
    serve=sub.add_parser('serve');serve.add_argument('--stable',required=True);serve.add_argument('--candidate',required=True);serve.add_argument('--token-file',required=True);serve.add_argument('--log',required=True);serve.add_argument('--port',type=int,default=8765);serve.add_argument('--seconds',type=int,default=600);serve.add_argument('--max-connections',type=int,default=200)
    client=sub.add_parser('request');client.add_argument('--port',type=int,default=8765);client.add_argument('--token-file',required=True);client.add_argument('--path',required=True,choices=['/health','/metrics','/predict','/feedback','/probe','/admin']);client.add_argument('--body');client.add_argument('--output',required=True)
    args=parser.parse_args()
    try:
        if not 1024<=args.port<=65535:raise ValueError('Port must be 1024–65535')
        if args.mode=='serve':
            if not 1<=args.seconds<=3600 or not 1<=args.max_connections<=200:raise ValueError('Bound runtime to 1–3600 seconds and 1–200 connections')
            service=Service(args.stable,args.candidate);token=secrets.token_urlsafe(32)
            # Token contents never enter HTTP logs. chmod precedes the write.
            with fresh_file(args.token_file) as f:
                Path(args.token_file).chmod(0o600);f.write(token)
            with fresh_file(args.log) as log,TeachingServer(args.port,service,token,log) as server:
                print(f'Local service: http://127.0.0.1:{args.port}; stop with Ctrl+C. Token in the specified file.',flush=True)
                deadline=time.monotonic()+args.seconds
                while time.monotonic()<deadline and server.connections<args.max_connections:server.handle_request()
        else:
            token=Path(args.token_file).read_text().strip()
            if not re.fullmatch(r'[A-Za-z0-9_-]{40,64}',token):raise ValueError('Invalid token file')
            data=None
            if args.body:
                with Path(args.body).open('rb') as f:data=f.read(16001)
                if len(data)>16000:raise ValueError('Body exceeds 16,000 bytes')
                strict_json(data)
            req=request.Request(f'http://127.0.0.1:{args.port}'+args.path,data=data,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
            with fresh_file(args.output) as f:
                started=time.perf_counter()
                try:
                    with request.urlopen(req,timeout=3) as response:status=response.status;payload=response.read(64001)
                except error.HTTPError as exc:status=exc.code;payload=exc.read(64001)
                elapsed=(time.perf_counter()-started)*1000
                if len(payload)>64000:raise ValueError('Response too large')
                json.dump({'status':status,'clientRoundTripMs':elapsed,'body':strict_json(payload)},f,indent=2,allow_nan=False)
            print(f'HTTP {status}; response written to {args.output}')
    except KeyboardInterrupt:print('Stopped; evidence files retained.')
    except (ValueError,TypeError,OSError,OverflowError) as exc:parser.error(str(exc))

if __name__=='__main__':main()
