#!/usr/bin/env python3
"""LingoBridge27 bounded offline 3D frame/truss worker.
Install a compatible OpenSeesPy installation yourself. Review licensing for your use.
Run: python3 Lingo-Solver27-worker.py job.json receipt.json
Only explicit linear-elastic N-mm-MPa 3D frame/truss jobs from the registered schema
are executed. No uploaded code, shell commands, dynamic scripts, or remote server.
"""
import argparse
import contextlib
import hashlib
import io
import json
import math
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from datetime import datetime, timezone

VERSION='lingo-solver27-worker-v1'
CONFIG={'analysis':'linear-static','ndm':3,'frameNdf':6,'trussNdf':3,'theory':'Euler-Bernoulli-or-axial-truss','steps':1,'sourceUnits':'N-mm-MPa-rad'}

def digest(raw):
    return hashlib.sha256(raw.encode('utf-8')).hexdigest()

def strict(raw):
    def pairs(values):
        result={}
        for key,value in values:
            if key in result or key in ('__proto__','constructor','prototype'): raise ValueError('Duplicate or reserved JSON key')
            result[key]=value
        return result
    def number(token):
        value=float(token)
        if not math.isfinite(value) or value==0. and any(c in '123456789' for c in token.lower().split('e')[0]): raise ValueError('Numeric overflow or underflow')
        return value
    value=json.loads(raw,object_pairs_hook=pairs,parse_float=number,parse_constant=lambda _:(_ for _ in ()).throw(ValueError('Nonfinite number')))
    def check(v,depth=0):
        if depth>24: raise ValueError('Excessive JSON nesting')
        if isinstance(v,float) and not math.isfinite(v): raise ValueError('Nonfinite number')
        if isinstance(v,dict):
            for item in v.values(): check(item,depth+1)
        if isinstance(v,list):
            for item in v: check(item,depth+1)
    check(value)
    return value

def fields(value,names):
    if not isinstance(value,dict) or set(value)!=set(names): raise ValueError('Missing or unsupported schema fields')

def finite(value,minimum=-1e18,maximum=1e18):
    if isinstance(value,bool) or not isinstance(value,(float,int)) or not math.isfinite(value) or value<minimum or value>maximum: raise ValueError('Unsupported bounded finite physical quantity')
    return value

def vector(value,n):
    if not isinstance(value,list) or len(value)!=n: raise ValueError('Unexpected vector length')
    for item in value: finite(item)
    return value

def dot(a,b): return sum(x*y for x,y in zip(a,b))
def sub(a,b): return [x-y for x,y in zip(a,b)]
def cross(a,b): return [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]
def norm(a): return math.sqrt(dot(a,a))
def unit(a):
    n=norm(a)
    if n<1e-10: raise ValueError('Degenerate local axis or member length')
    return [v/n for v in a]

def identifier(value):
    if not isinstance(value,str) or not re.fullmatch('[A-Za-z][A-Za-z0-9_-]{0,63}',value): raise ValueError('Unsupported semantic identifier')

def model_check(model,cases):
    fields(model,['format','kind','units','nodes','members','loadCases','unsupportedFeatures'])
    if model['format']!='LingoStructuralModel27' or model['kind'] not in ('frame3d','truss3d') or model['units']!={'length':'mm','force':'N','stress':'MPa','rotation':'rad'} or model['unsupportedFeatures']!=[]: raise ValueError('Unsupported physical model or units')
    if not isinstance(model['nodes'],list) or not 2<=len(model['nodes'])<=24 or not isinstance(model['members'],list) or not 1<=len(model['members'])<=48 or not isinstance(model['loadCases'],list) or not 1<=len(model['loadCases'])<=8 or not isinstance(cases,list) or not 1<=len(cases)<=16: raise ValueError('Bounded model size exceeded')
    nodes={}
    for n in model['nodes']:
        fields(n,['id','xyzMm','restraints']);identifier(n['id'])
        if n['id'] in nodes: raise ValueError('Duplicate node identifier')
        vector(n['xyzMm'],3)
        if any(abs(v)>1e6 for v in n['xyzMm']) or not isinstance(n['restraints'],list) or len(n['restraints'])!=6 or any(type(v) is not bool for v in n['restraints']): raise ValueError('Invalid coordinates or restraints')
        if model['kind']=='truss3d' and not all(n['restraints'][3:]): raise ValueError('Truss unused rotations must be explicitly restrained')
        nodes[n['id']]=n
    members=set();touched=set();graph={key:set() for key in nodes}
    for m in model['members']:
        fields(m,['id','start','end','youngMPa','shearMPa','areaMm2','iyMm4','izMm4','torsionMm4','localY','source']);identifier(m['id'])
        if m['id'] in members or m['start'] not in nodes or m['end'] not in nodes or m['start']==m['end']: raise ValueError('Invalid member identity or endpoints')
        members.add(m['id']);touched.update([m['start'],m['end']]);graph[m['start']].add(m['end']);graph[m['end']].add(m['start'])
        for key,maximum in [('youngMPa',1e6),('shearMPa',1e6),('areaMm2',1e9),('iyMm4',1e18),('izMm4',1e18),('torsionMm4',1e18)]: finite(m[key],1e-9,maximum)
        vector(m['localY'],3)
        x=unit(sub(nodes[m['end']]['xyzMm'],nodes[m['start']]['xyzMm']))
        if any(abs(v)>1e6 for v in m['localY']) or norm(sub(nodes[m['end']]['xyzMm'],nodes[m['start']]['xyzMm']))<.001 or norm(cross(x,m['localY']))<1e-8 or not isinstance(m['source'],str) or not 1<=len(m['source'])<=1000: raise ValueError('Invalid member axes, length or source')
    if touched!=set(nodes): raise ValueError('Unconnected nodes')
    visited=set();pending=[next(iter(nodes))]
    while pending:
        n=pending.pop()
        if n not in visited: visited.add(n);pending.extend(graph[n]-visited)
    if visited!=set(nodes): raise ValueError('Disconnected model')
    load_ids=set()
    for lc in model['loadCases']:
        fields(lc,['id','name','loads']);identifier(lc['id'])
        if lc['id'] in load_ids or not isinstance(lc['name'],str) or not 1<=len(lc['name'])<=220 or not isinstance(lc['loads'],list) or not 1<=len(lc['loads'])<=24: raise ValueError('Invalid load case')
        load_ids.add(lc['id']);seen=set()
        for load in lc['loads']:
            fields(load,['node','values']);vector(load['values'],6)
            if load['node'] not in nodes or load['node'] in seen or any(abs(v)>1e9 for v in load['values']) or model['kind']=='truss3d' and any(load['values'][3:]): raise ValueError('Unsupported nodal force/moment')
            seen.add(load['node'])
    seen=set()
    for c in cases:
        fields(c,['id','loadCaseId','loadFactor','youngScale','areaScale']);identifier(c['id'])
        if c['id'] in seen or c['loadCaseId'] not in load_ids: raise ValueError('Invalid study case identity')
        seen.add(c['id']);finite(c['loadFactor'],-100,100);finite(c['youngScale'],.01,100);finite(c['areaScale'],.01,100)

def local_matrix(model,m,c):
    nodes={n['id']:n for n in model['nodes']};d=sub(nodes[m['end']]['xyzMm'],nodes[m['start']]['xyzMm']);length=norm(d);x=unit(d);z=unit(cross(x,m['localY']));y=cross(z,x);rotation=[x,y,z]
    transform=[[0.]*12 for _ in range(12)]
    for block in range(4):
        for i in range(3):
            for j in range(3): transform[3*block+i][3*block+j]=rotation[i][j]
    k=[[0.]*12 for _ in range(12)];young=m['youngMPa']*c['youngScale'];shear=m['shearMPa']*c['youngScale']
    def axial(a,b,value):
        k[a][a]+=value;k[b][b]+=value;k[a][b]-=value;k[b][a]-=value
    axial(0,6,young*m['areaMm2']*c['areaScale']/length)
    if model['kind']=='frame3d':
        axial(3,9,shear*m['torsionMm4']/length)
        for indices,inertia,sign in [([1,5,7,11],m['izMm4'],1),([2,4,8,10],m['iyMm4'],-1)]:
            l=length;s=sign;values=[[12,s*6*l,-12,s*6*l],[s*6*l,4*l*l,-s*6*l,2*l*l],[-12,-s*6*l,12,-s*6*l],[s*6*l,2*l*l,-s*6*l,4*l*l]]
            for i in range(4):
                for j in range(4): k[indices[i]][indices[j]]+=values[i][j]*young*inertia/l**3
    return k,transform,z

def run_direct(model,cases):
    import openseespy.opensees as ops
    output=[]
    for c in cases:
        ops.wipe();ndf=6 if model['kind']=='frame3d' else 3;ops.model('basic','-ndm',3,'-ndf',ndf)
        tags={n['id']:i+1 for i,n in enumerate(model['nodes'])}
        for n in model['nodes']:
            ops.node(tags[n['id']],*n['xyzMm']);ops.fix(tags[n['id']],*[int(v) for v in n['restraints'][:ndf]])
        for index,m in enumerate(model['members']):
            tag=index+1
            if ndf==6:
                _k,_t,z=local_matrix(model,m,c);ops.geomTransf('Linear',tag,*z)
                ops.element('elasticBeamColumn',tag,tags[m['start']],tags[m['end']],m['areaMm2']*c['areaScale'],m['youngMPa']*c['youngScale'],m['shearMPa']*c['youngScale'],m['torsionMm4'],m['iyMm4'],m['izMm4'],tag)
            else:
                ops.uniaxialMaterial('Elastic',tag,m['youngMPa']*c['youngScale']);ops.element('truss',tag,tags[m['start']],tags[m['end']],m['areaMm2']*c['areaScale'],tag)
        lc=next(l for l in model['loadCases'] if l['id']==c['loadCaseId']);applied={n['id']:[0.]*6 for n in model['nodes']}
        ops.timeSeries('Linear',1);ops.pattern('Plain',1,1)
        for load in lc['loads']:
            values=[v*c['loadFactor'] for v in load['values']];applied[load['node']]=values;ops.load(tags[load['node']],*values[:ndf])
        ops.constraints('Plain');ops.numberer('Plain');ops.system('BandGeneral');ops.test('NormDispIncr',1e-12,10);ops.algorithm('Linear');ops.integrator('LoadControl',1.);ops.analysis('Static');code=ops.analyze(1)
        print('case='+c['id']+'; actual OpenSees '+model['kind']+'; analyze(1)='+str(code)+'; '+str(len(tags))+' nodes; '+str(len(model['members']))+' members; N-mm-MPa-rad')
        if code!=0: raise RuntimeError('OpenSees failed case '+c['id']+' with code '+str(code))
        ops.reactions();nodes=[];force=[0.,0.,0.];moment=[0.,0.,0.];energy=0.;residual=0.;moment_residual=0.;by_id={}
        for n in model['nodes']:
            u=list(ops.nodeDisp(tags[n['id']]));reaction=list(ops.nodeReaction(tags[n['id']]))
            if ndf==3: u.extend([0.,0.,0.]);reaction.extend([0.,0.,0.])
            if any(not math.isfinite(v) for v in u+reaction): raise ValueError('Nonfinite OpenSees response')
            by_id[n['id']]=u;nodes.append({'id':n['id'],'displacement':u,'reaction':reaction});f=[applied[n['id']][j]+reaction[j] for j in range(3)];torque=cross(n['xyzMm'],f)
            for j in range(3): force[j]+=f[j];moment[j]+=torque[j]+applied[n['id']][j+3]+reaction[j+3]
            for j in range(ndf):
                if not n['restraints'][j]:
                    if j<3: residual=max(residual,abs(reaction[j]))
                    else: moment_residual=max(moment_residual,abs(reaction[j]))
        members=[]
        for index,m in enumerate(model['members']):
            # These end actions come from the actual external element response.
            if ndf==6:
                actions=list(ops.eleResponse(index+1,'localForce'))
                if len(actions)!=12: raise RuntimeError('OpenSees localForce response is incomplete')
            else:
                axial=ops.eleResponse(index+1,'axialForce')
                value=float(axial[0] if isinstance(axial,list) else axial);actions=[-value,0.,0.,0.,0.,0.,value,0.,0.,0.,0.,0.]
            k,t,_=local_matrix(model,m,c);global_u=by_id[m['start']]+by_id[m['end']];local_u=[dot(row,global_u) for row in t];energy+=.5*dot(local_u,actions);members.append({'id':m['id'],'localEndForces':actions})
        work=.5*sum(dot(by_id[n['id']],applied[n['id']]) for n in model['nodes'])
        output.append({'id':c['id'],'status':'complete','reason':'Actual installed OpenSees response','nodes':nodes,'members':members,'strainEnergyNmm':energy,'maxFreeResidualN':residual,'maxFreeResidualMomentNmm':moment_residual,'forceBalanceN':force,'momentBalanceNmm':moment,'workDefectNmm':abs(energy-work)})
    version=str(ops.version());ops.wipe();return {'solverVersion':version,'cases':output}

def run_opensees(model,cases):
    with tempfile.TemporaryDirectory(prefix='lingo27-native-') as temporary:
        folder=Path(temporary);(folder/'input.json').write_text(json.dumps({'model':model,'cases':cases},allow_nan=False),encoding='utf-8');environment=os.environ.copy();environment.setdefault('MPIR_CVAR_CH3_INTERFACE_HOSTNAME','127.0.0.1')
        child=subprocess.run([sys.executable,str(Path(__file__).resolve()),'--internal-opensees',str(folder/'input.json'),str(folder/'output.json')],shell=False,env=environment,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,timeout=180)
        print(child.stdout[:200000])
        if child.returncode or not (folder/'output.json').is_file():
            if 'ModuleNotFoundError' in child.stdout or 'ImportError' in child.stdout: raise ModuleNotFoundError('A compatible OpenSeesPy installation is unavailable; see captured log.')
            raise RuntimeError('Native OpenSees process failed with exit '+str(child.returncode))
        return strict((folder/'output.json').read_text(encoding='utf-8'))

def utc(): return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00','Z')

def run_solver_job27(job):
    fields(job,['format','version','bodyJSON','bodySHA256'])
    if job['format']!='LingoSolverJob27' or job['version']!=VERSION or not isinstance(job['bodyJSON'],str) or len(job['bodyJSON'].encode('utf-8'))>600000 or digest(job['bodyJSON'])!=job['bodySHA256']: raise ValueError('Unsupported job version or original bytes')
    body=strict(job['bodyJSON']);fields(body,['adapter','modelJSON','modelSHA256','casesJSON','casesSHA256','configurationJSON','configurationSHA256','workerSourceSHA256','sourceRefs','consent'])
    own=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
    if body['workerSourceSHA256']!=own or body['consent'] is not True or body['adapter'] not in ('openseespy','abaqus','sap2000') or strict(body['configurationJSON'])!=CONFIG: raise ValueError('Unsupported worker source, configuration, adapter or consent')
    for key in ('model','cases','configuration'):
        if not isinstance(body[key+'JSON'],str) or digest(body[key+'JSON'])!=body[key+'SHA256']: raise ValueError('Exact model/cases/settings source changed')
    model=strict(body['modelJSON']);cases=strict(body['casesJSON']);model_check(model,cases)
    if not isinstance(body['sourceRefs'],list) or len(body['sourceRefs'])>32: raise ValueError('Unsupported source references')
    seen=set()
    for ref in body['sourceRefs']:
        fields(ref,['id','sha256'])
        if not isinstance(ref['id'],str) or not re.fullmatch('[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}',ref['id']) or ref['id'] in seen or not isinstance(ref['sha256'],str) or not re.fullmatch('[a-f0-9]{64}',ref['sha256']): raise ValueError('Invalid source identity or fingerprint')
        seen.add(ref['id'])
    started=utc();stream=io.StringIO();output=None;status='failed';version=''
    with contextlib.redirect_stdout(stream),contextlib.redirect_stderr(stream):
        try:
            if body['adapter']!='openseespy': raise ModuleNotFoundError(body['adapter']+' adapter is uncommissioned; no licensed profile or fabricated response is supplied.')
            result=run_opensees(model,cases);version=result['solverVersion'];output={'cases':result['cases']};status='complete'
        except (ImportError,ModuleNotFoundError) as error: status='unconfigured';print(type(error).__name__+': '+str(error))
        except Exception as error: print(type(error).__name__+': '+str(error))
    output_json=json.dumps(output,separators=(',',':'),allow_nan=False);log=stream.getvalue()[:250000]
    return {'format':'LingoSolverReceipt27','version':VERSION,'jobSHA256':job['bodySHA256'],'modelSHA256':body['modelSHA256'],'casesSHA256':body['casesSHA256'],'configurationSHA256':body['configurationSHA256'],'workerSourceSHA256':own,'adapter':body['adapter'],'solverVersion':version,'status':status,'startedAt':started,'finishedAt':utc(),'outputJSON':output_json,'outputSHA256':digest(output_json),'log':log,'logSHA256':digest(log)}

def main():
    parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('job');parser.add_argument('receipt');args=parser.parse_args();raw=Path(args.job).read_text(encoding='utf-8')
    if len(raw.encode('utf-8'))>700000: raise ValueError('Job exceeds 700 kB')
    receipt=run_solver_job27(strict(raw));Path(args.receipt).write_text(json.dumps(receipt,indent=2,allow_nan=False),encoding='utf-8');print('Wrote '+receipt['status']+' receipt: '+args.receipt);return 0 if receipt['status']=='complete' else 2

if __name__=='__main__':
    try:
        if len(sys.argv)==4 and sys.argv[1]=='--internal-opensees':
            data=strict(Path(sys.argv[2]).read_text(encoding='utf-8'));fields(data,['model','cases']);model_check(data['model'],data['cases']);result=run_direct(data['model'],data['cases']);Path(sys.argv[3]).write_text(json.dumps(result,allow_nan=False),encoding='utf-8');sys.exit(0)
        sys.exit(main())
    except Exception as error: print(type(error).__name__+': '+str(error),file=sys.stderr);sys.exit(1)
