"""Deeper ERP acceptance checks. Run after smoke + extended on an isolated demo."""
import base64
import datetime
import http.cookiejar
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid

BASE=sys.argv[1] if len(sys.argv)>1 else 'http://127.0.0.1:8089'
TODAY=datetime.date.today().isoformat()
UID=uuid.uuid4().hex[:8]
checks=0
def expect(ok,text):
    global checks
    if not ok: raise AssertionError(text)
    checks+=1
    print('PASS',text,flush=True)
class Client:
    def __init__(self,role,password='SchoolDemo!2026'):
        self.opener=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
        self.get('?page=login');self.post('login',{'email':role+'@demo.school','password':password})
        expect('Welcome back' in self.last or 'Change your temporary password' in self.last,role+' signs in')
    def get(self,path):
        try:
            with self.opener.open(BASE+'/'+path) as r:self.raw=r.read();self.last=self.raw.decode('utf-8-sig',errors='replace');return r.status,self.last
        except urllib.error.HTTPError as e:self.raw=e.read();self.last=self.raw.decode('utf-8-sig',errors='replace');return e.code,self.last
    def token(self):
        token=re.search(r'name="csrf" value="([a-f0-9]+)"',self.last)
        if not token:self.get('?page=account');token=re.search(r'name="csrf" value="([a-f0-9]+)"',self.last)
        return token.group(1)
    def post(self,action,data,ret='?page=dashboard'):
        data={'csrf':self.token(),'action':action,'return_to':ret,**data}
        req=urllib.request.Request(BASE+'/',urllib.parse.urlencode(data).encode())
        with self.opener.open(req) as r:self.last=r.read().decode('utf-8-sig')
        return self.last
    def document(self,student,title):
        boundary='ERP'+uuid.uuid4().hex
        data={'csrf':self.token(),'action':'erp.document.upload','return_to':'?page=student&id='+str(student),'student_id':str(student),'title':title,'category':'general'}
        body=b''
        for k,v in data.items():body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
        body+=f'--{boundary}\r\nContent-Disposition: form-data; name="document"; filename="fixture.pdf"\r\nContent-Type: application/pdf\r\n\r\n'.encode()+b'%PDF-1.4\n% synthetic ERP fixture\n%%EOF\n'+f'\r\n--{boundary}--\r\n'.encode()
        with self.opener.open(urllib.request.Request(BASE+'/',body,{'Content-Type':'multipart/form-data; boundary='+boundary})) as r:self.last=r.read().decode('utf-8-sig')
        return self.last
def tr(body,marker):return next(x for x in re.findall(r'<tr\b[^>]*>(.*?)</tr>',body,re.S) if marker in x)
def field(body,name):return re.search('name="'+re.escape(name)+'" value="([^"]+)"',body).group(1)

admin=Client('admin');head=Client('head');bursar=Client('bursar');teacher=Client('teacher');parent=Client('parent');student=Client('student');cashier=Client('cashier')
for page in ['admissions','promotion','structure','timetable','homework','finance','invoices','suppliers','reconciliation']:
    code,body=admin.get('?page='+page)
    expect(code==200 and 'temporarily unavailable' not in body and 'Warning:' not in body,'expanded page '+page)
for page in ['admissions','promotion','structure','finance','suppliers','reconciliation']:
    expect(parent.get('?page='+page)[0]==403,'parent denied administrative module '+page)
for page in ['timetable','homework','invoices']:
    expect(parent.get('?page='+page)[0]==200,'parent can view permitted module '+page)
for page in ['finance','suppliers','reconciliation']:
    expect(cashier.get('?page='+page)[0]==403,'cashier denied accounting module '+page)

admin.get('?page=student&id=1')
body=admin.post('erp.student.update',{'student_id':'1','first_name':'Anesu','last_name':'Moyo','dob':'2016-01-12','gender':'Male','address':'Updated test address','prior_school':'Prior test school','emergency_name':'Emergency test contact','emergency_phone':'000000','nationality':'Zimbabwean'},'?page=student&id=1')
expect('Student profile updated' in body and 'Prior test school' in body,'profile corrections and emergency contacts saved')
body=admin.document(1,'Own-child test file '+UID)
expect('Document stored securely' in body,'student document stored outside public website')
doc_id=re.search(r'page=document&id=(\d+)',tr(body,'Own-child test file '+UID)).group(1)
expect(parent.get('?page=document&id='+doc_id)[0]==200,'linked parent can download own-child file')
expect(teacher.get('?page=document&id='+doc_id)[0]==403,'teacher denied identity documents')
admin.get('?page=student&id=2');body=admin.document(2,'Other-child test file '+UID)
other_doc=re.search(r'page=document&id=(\d+)',tr(body,'Other-child test file '+UID)).group(1)
expect(parent.get('?page=document&id='+other_doc)[0]==403,'parent denied another child’s document')

admin.get('?page=admissions')
body=admin.post('erp.admission.create',{'first_name':'Applicant'+UID,'last_name':'Test','dob':'2015-01-01','class_id':'1','guardian_name':'Applicant Guardian','guardian_email':'applicant@demo.school','guardian_phone':'000123','notes':'Synthetic acceptance workflow'},'?page=admissions')
expect('Application recorded separately' in body,'application separate from student register')
aid=field(tr(body,'Applicant'+UID),'admission_id')
for status in ['offered','accepted']:
    body=admin.post('erp.admission.transition',{'admission_id':aid,'status':status},'?page=admissions');expect('Application moved to '+status in body,'admission transition '+status)
body=admin.post('erp.admission.transition',{'admission_id':aid,'status':'enrolled','student_number':'APP-STU-'+UID},'?page=admissions')
expect('Application moved to enrolled' in body,'accepted application creates student and guardian')
body=admin.post('erp.admission.transition',{'admission_id':aid,'status':'enrolled','student_number':'DUP-'+UID},'?page=admissions')
expect('transition is not allowed' in body,'admission cannot enrol twice')

# Separate subject permissions from class-register responsibility.
admin.get('?page=users');body=admin.post('user.create',{'name':'Subject Teacher '+UID,'email':'subject'+UID+'@demo.school','role':'teacher','password':'TemporaryPass!2026'},'?page=users')
expect('Staff account created' in body,'subject teacher account created')
teacher_id=field(tr(body,'subject'+UID+'@demo.school'),'user_id')
admin.get('?page=structure');body=admin.post('erp.assignment.save',{'class_id':'1','subject_id':'1','teacher_id':teacher_id},'?page=structure')
expect('Subject teaching assignment saved' in body,'subject can be assigned independently of class teacher')
new_teacher=Client('subject'+UID,'TemporaryPass!2026')
new_teacher.get('?page=account');body=new_teacher.post('password.change',{'current_password':'TemporaryPass!2026','new_password':'NewTeacherPass!2026','confirm_password':'NewTeacherPass!2026'},'?page=account')
expect('Password updated' in body,'new staff must change temporary password')
teacher.get('?page=account');body=teacher.post('marks.save',{'assessment_id':'1'},'?page=academics')
expect('assigned to another teacher' in body,'class teacher cannot change another subject teacher’s marks')
expect(new_teacher.get('?page=academics&id=1')[0]==200,'assigned subject teacher can open subject register')
new_teacher.get('?page=homework')
assignment_id=re.search(r'<option value="(\d+)"\s*>Grade 4 Blue · Mathematics · Subject Teacher '+UID,new_teacher.last).group(1)

admin.get('?page=timetable');body=admin.post('erp.timetable.create',{'assignment_id':assignment_id,'weekday':'1','starts_at':'08:00','ends_at':'09:00','room':'Test Room '+UID},'?page=timetable')
expect('Lesson scheduled' in body,'lesson scheduled for assigned teacher')
body=admin.post('erp.timetable.create',{'assignment_id':'1','weekday':'1','starts_at':'08:30','ends_at':'09:30','room':'Different Room '+UID},'?page=timetable')
expect('Timetable clash' in body,'overlapping class lesson rejected')
body=parent.get('?page=timetable')[1]
expect('Test Room '+UID in body,'linked family sees class timetable')

new_teacher.get('?page=homework');body=new_teacher.post('erp.homework.create',{'assignment_id':assignment_id,'title':'Math task '+UID,'instructions':'Explain your solution clearly.','due_date':(datetime.date.today()+datetime.timedelta(days=2)).isoformat(),'due_time':'16:00'},'?page=homework')
expect('Homework assigned' in body,'subject teacher assigns homework')
hid=re.search(r'page=homework&id=(\d+)',tr(body,'Math task '+UID)).group(1)
student.get('?page=homework&id='+hid);body=student.post('erp.homework.submit',{'homework_id':hid,'response':'My synthetic test solution'},'?page=homework&id='+hid)
expect('Work submitted to your teacher' in body,'student submits own homework')
new_teacher.get('?page=homework&id='+hid);submission_id=field(new_teacher.last,'submission_id');body=new_teacher.post('erp.homework.review',{'homework_id':hid,'submission_id':submission_id,'feedback':'Well explained. Show your working next time.'},'?page=homework&id='+hid)
expect('Feedback returned' in body,'teacher returns homework feedback')
expect('Well explained' in parent.get('?page=homework&id='+hid)[1],'parent sees only linked learner feedback')
parent.get('?page=account');body=parent.post('erp.homework.submit',{'homework_id':hid,'response':'Unauthorized'},'?page=homework')
expect('Only the student' in body,'parent cannot impersonate student submission')

# The chart account ids are exposed in account options; use codes to resolve them.
bursar.get('?page=finance')
def account_id(code):
    return re.search(r'<option value="(\d+)"\s*>'+code+r' ·',bursar.last).group(1)
cash=account_id('1000');bank=account_id('1010');expense=account_id('5000')
body=bursar.post('erp.ledger.request',{'entry_date':TODAY,'currency':'USD','description':'Operating expense '+UID,'line[0][account_id]':expense,'line[0][debit]':'12.34','line[1][account_id]':cash,'line[1][credit]':'12.34'},'?page=finance')
expect('Balanced journal submitted' in body,'balanced expense journal requested')
head.get('?page=finance');jid=field(tr(head.last,'Operating expense '+UID),'request_id')
bursar.get('?page=finance');body=bursar.post('erp.ledger.approve',{'request_id':jid},'?page=finance')
expect('Another authorised person' in body,'journal maker cannot approve own entry')
head.get('?page=finance');body=head.post('erp.ledger.approve',{'request_id':jid},'?page=finance')
expect('Approved entries are posted once' in body,'independent reviewer posts balanced journal')
body=head.post('erp.ledger.approve',{'request_id':jid},'?page=finance')
expect('already been reviewed' in body,'journal cannot be posted twice')
bursar.get('?page=finance');body=bursar.post('erp.ledger.request',{'entry_date':TODAY,'currency':'USD','description':'Unbalanced '+UID,'line[0][account_id]':expense,'line[0][debit]':'10','line[1][account_id]':cash,'line[1][credit]':'9'},'?page=finance')
expect('journal must balance' in body,'unbalanced journal rejected')

bursar.get('?page=suppliers');body=bursar.post('erp.supplier.create',{'name':'Test Supplier '+UID,'email':'supplier@demo.school','phone':'000000'},'?page=suppliers')
expect('Supplier created' in body,'supplier registered')
supplier_id=re.search(r'<option value="(\d+)"\s*>Test Supplier '+UID,body).group(1)
body=bursar.post('erp.supplier.bill',{'supplier_id':supplier_id,'reference':'BILL-'+UID,'description':'Teaching supplies','amount':'100','currency':'USD','expense_account':expense,'billed_on':TODAY,'due_on':TODAY},'?page=suppliers')
expect('Supplier bill submitted' in body,'bill kept separate from cash payment')
head.get('?page=suppliers');bill_id=field(tr(head.last,'BILL-'+UID),'bill_id');body=head.post('erp.supplier.bill_approve',{'bill_id':bill_id},'?page=suppliers')
expect('Supplier bill reviewed' in body,'independent supplier bill approval posts payables')
bursar.get('?page=suppliers');body=bursar.post('erp.supplier.payment',{'bill_id':bill_id,'amount':'40','account_id':bank,'paid_on':TODAY,'reference':'SUP-PAY-'+UID},'?page=suppliers')
expect('Payment record submitted' in body,'supplier payment requested separately')
head.get('?page=suppliers');supplier_payment=field(tr(head.last,'SUP-PAY-'+UID),'payment_id');body=head.post('erp.supplier.payment_approve',{'payment_id':supplier_payment},'?page=suppliers')
expect('Supplier payment record reviewed' in body,'supplier payment clears payables without duplicate expense')
bursar.get('?page=reconciliation');body=bursar.post('erp.bank.line',{'account_id':bank,'entry_date':TODAY,'currency':'USD','direction':'out','amount':'40','reference':'BANK-'+UID},'?page=reconciliation')
expect('Statement line recorded' in body,'bank statement recorded')
head.get('?page=reconciliation');statement_id=re.search(r'data-dialog="bank-match-(\d+)"',tr(head.last,'BANK-'+UID)).group(1)
book_line=re.search(r'<option value="(\d+)"\s*>#\d+ · [^<]*Supplier payment SUP-PAY-'+UID,head.last).group(1)
body=head.post('erp.bank.match',{'statement_id':statement_id,'line_id':book_line},'?page=reconciliation')
expect('Statement line matched' in body,'statement reconciles to exact posted book movement')

bursar.get('?page=invoices');body=bursar.post('erp.opening.request',{'student_id':'2','amount':'42','direction':'owing','currency':'USD','as_of':TODAY,'reference':'SIGNED-OPENING-'+UID},'?page=invoices')
expect('Opening balance submitted' in body,'opening amount requires signed source and review')
head.get('?page=invoices');oid=field(tr(head.last,'SIGNED-OPENING-'+UID),'opening_id');body=head.post('erp.opening.approve',{'opening_id':oid},'?page=invoices')
expect('Opening balance reviewed' in body,'opening receivable posts against reserves')
bursar.get('?page=invoices');body=bursar.post('erp.allocate.save',{'payment_id':'1','allocation[1]':'10'},'?page=invoices')
expect('Invoice allocations saved' in body,'confirmed receipt allocated without another income entry')
body=bursar.post('erp.allocate.save',{'payment_id':'1','allocation[1]':'99999'},'?page=invoices')
expect('exceeds an invoice balance' in body or 'exceed funds available' in body,'over-allocation rejected')

day=(datetime.date.today()-datetime.timedelta(days=1)).isoformat()
bursar.get('?page=finance');body=bursar.post('erp.period.create',{'starts_on':day,'ends_on':day,'label':'Test closed day '+UID},'?page=finance')
expect('Financial period created' in body,'financial period configured')
head.get('?page=finance');pid=re.search(r'data-dialog="period-(\d+)"',tr(head.last,'Test closed day '+UID)).group(1)
body=head.post('erp.period.close',{'period_id':pid,'reason':'Reconciled test day'},'?page=finance')
expect('Financial period closed' in body,'head closes completed period')
cashier.get('?page=fees');body=cashier.post('payment.create',{'student_id':'1','amount':'1','currency':'USD','method':'cash','reference':'Closed date attempt','paid_on':day,'idempotency_key':uuid.uuid4().hex},'?page=fees')
expect('financial period is closed' in body,'closed period blocks backdated payment')
head.get('?page=finance');body=head.post('erp.period.reopen',{'period_id':pid,'reason':'Authorised test correction'},'?page=finance')
expect('reopened with an audit record' in body,'period reopening requires a recorded reason')

admin.get('?page=promotion');body=admin.post('erp.promotion.preview',{'source_class':'5','target_class':'6','academic_year':str(datetime.date.today().year+1)},'?page=promotion')
expect('Promotion preview prepared' in body,'batch promotion preview prepared')
token=field(body,'batch_token');ids=re.findall(r'name="outcome\[(\d+)\]"',body)
payload={'batch_token':token,**{f'outcome[{i}]':'skip' for i in ids}}
payload[f'outcome[{ids[0]}]']='promote';payload[f'outcome[{ids[1]}]']='repeat';payload[f'outcome[{ids[2]}]']='completed'
body=admin.post('erp.promotion.commit',payload,'?page=promotion')
expect('3 learners processed' in body,'bulk promotion handles promote repeat complete and skip')
body=admin.post('erp.promotion.commit',payload,'?page=promotion')
expect('already completed' in body,'promotion retry cannot duplicate enrolment histories')
head.get('?page=results');body=head.post('erp.report.publish',{'class_id':'1','reason':'Blocked incomplete term report test'},'?page=results')
expect('weights totalling 100%' in body,'official report blocked until all subject weights are complete')
# An isolated complete class demonstrates official publication and revision preservation.
admin.get('?page=settings');body=admin.post('class.create',{'name':'Report Class '+UID,'section':'primary','teacher_id':'6'},'?page=settings')
report_class=re.search(r'<strong>Report Class '+UID+r'</strong>.*?name="class_id" value="(\d+)"',body,re.S).group(1)
admin.get('?page=structure');body=admin.post('erp.assignment.save',{'class_id':report_class,'subject_id':'1','teacher_id':'6'},'?page=structure')
expect('Subject teaching assignment saved' in body,'report class has an explicit subject offering')
admin.get('?page=students');body=admin.post('student.create',{'student_number':'REPORT-'+UID,'first_name':'Report','last_name':'Learner'+UID,'gender':'Not specified','class_id':report_class,'admitted_on':TODAY},'?page=students')
report_student=re.search(r'page=student&id=(\d+)',tr(body,'REPORT-'+UID)).group(1)
admin.get('?page=student&id='+report_student);admin.post('guardian.link',{'student_id':report_student,'guardian_id':'1','relationship':'Guardian','receive_emails':'1'},'?page=student&id='+report_student)
teacher.get('?page=academics');body=teacher.post('assessment.create',{'class_id':report_class,'subject_id':'1','title':'Complete report test '+UID,'maximum':'100','weight':'100'},'?page=academics')
expect('Assessment created' in body,'subject teacher creates complete weighted assessment')
report_assessment=re.search(r'page=academics&id=(\d+)',tr(body,'Complete report test '+UID)).group(1)
teacher.get('?page=academics&id='+report_assessment);body=teacher.post('marks.save',{'assessment_id':report_assessment,f'marks[{report_student}][status]':'marked',f'marks[{report_student}][score]':'80',f'marks[{report_student}][comment]':'Official report fixture'},'?page=academics&id='+report_assessment)
expect('Draft marks saved' in body,'complete class mark recorded')
teacher.post('assessment.submit',{'assessment_id':report_assessment},'?page=academics&id='+report_assessment)
head.get('?page=academics&id='+report_assessment);head.post('assessment.review',{'assessment_id':report_assessment},'?page=academics&id='+report_assessment);head.post('assessment.publish',{'assessment_id':report_assessment},'?page=academics&id='+report_assessment)
head.get('?page=results');body=head.post('erp.report.publish',{'class_id':report_class,'reason':'Approved complete class test'},'?page=results')
expect('1 official report version(s) published' in body,'complete class publishes immutable official report')
report_id=re.search(r'page=report_card&id='+report_student+r'&report_id=(\d+)',body).group(1)
expect('Official academic report' in parent.get('?page=report_card&id='+report_student+'&report_id='+report_id)[1],'linked parent opens official report version')
head.get('?page=structure');head.post('erp.grading.save',{'section':'primary','name':'Changed test scheme','grade[0][label]':'Exceptional','grade[0][minimum]':'0'},'?page=structure')
body=parent.get('?page=report_card&id='+report_student+'&report_id='+report_id)[1]
expect('Exceptional' not in body,'published report grade does not change with later grading settings')
head.get('?page=results');body=head.post('erp.report.publish',{'class_id':report_class,'reason':'Approved grading correction'},'?page=results')
expect('1 official report version(s) published' in body,'approved correction creates a new report version')
head.get('?page=results');body=head.post('erp.report.publish',{'class_id':report_class,'reason':'Unchanged retry'},'?page=results')
expect('0 official report version(s) published' in body,'unchanged official publication produces no duplicate version or alert')
bursar.get('?page=invoices');body=bursar.post('erp.adjustment.request',{'invoice_id':'1','amount':'2.00','direction':'credit','reason':'Charge correction '+UID},'?page=invoices')
expect('Fee correction submitted' in body,'fee credit note requested against original invoice')
head.get('?page=invoices');adjustment_id=field(tr(head.last,'Charge correction '+UID),'adjustment_id');body=head.post('erp.adjustment.approve',{'adjustment_id':adjustment_id},'?page=invoices')
expect('Fee correction reviewed' in body,'independent correction updates account and ledger')
expect('Charge correction '+UID in parent.get('?page=statement&id=1')[1],'fee correction appears on family statement')
head.get('?page=suppliers');body=head.post('erp.supplier.bill_reverse',{'bill_id':bill_id,'reason':'Correction test'},'?page=suppliers')
expect('payment records before reversing the bill' in body,'paid supplier bill cannot be reversed before its payment')
body=head.post('erp.supplier.payment_reverse',{'payment_id':supplier_payment,'reason':'Payment correction test'},'?page=suppliers')
expect('Supplier payment record reversed' in body,'supplier payment correction posts an offsetting journal')
body=head.post('erp.supplier.bill_reverse',{'bill_id':bill_id,'reason':'Bill correction test'},'?page=suppliers')
expect('Supplier bill reversed with an offsetting journal' in body,'supplier bill correction preserves original record')
body=head.post('erp.supplier.bill_reverse',{'bill_id':bill_id,'reason':'Repeated test'},'?page=suppliers')
expect('Only an approved supplier bill' in body,'supplier bill cannot be reversed twice')
for kind in ['ledger','supplier_bills']:
    code,body=head.get('?page=export&kind='+kind);expect(code==200 and len(body)>100,'accounting CSV export '+kind)
expect(cashier.get('?page=export&kind=ledger')[0]==403,'cashier cannot export accounting ledger')
expect(parent.get('?page=timetable&class_id=4')[0]==403,'parent cannot switch timetable to an unlinked class')
print(f'\n{checks} deeper ERP checks passed.',flush=True)
