#!/usr/bin/env python3
"""Bounded response-HTML checks against an explicit release manifest. Python 3.9+."""
import argparse,datetime,hashlib,http.client,json,re,sys
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urlsplit,urljoin
from xml.etree import ElementTree as ET
VERSION='1.0.0'
LIMIT=2_000_000
class Page(HTMLParser):
    def __init__(self):
        super().__init__();self.canonical=[];self.alternates=[];self.robots=[];self.titles=[];self.in_title=False;self.h1=0
    def handle_starttag(self,t,attrs):
        a=dict(attrs)
        if t=='title':self.in_title=True;self.titles.append('')
        if t=='h1':self.h1+=1
        if t=='link' and 'canonical' in a.get('rel','').split():self.canonical.append(a.get('href',''))
        if t=='link' and 'alternate' in a.get('rel','').split() and 'hreflang' in a:self.alternates.append((a['hreflang'],a.get('href','')))
        if t=='meta' and a.get('name','').lower() in ['robots','googlebot']:self.robots.append(a.get('content',''))
    def handle_endtag(self,t):
        if t=='title':self.in_title=False
    def handle_data(self,s):
        if self.in_title:self.titles[-1]+=s

def run(manifest,origin,output):
    out=Path(output);out.mkdir(parents=True,exist_ok=False)
    origin=origin.rstrip('/');parsed=urlsplit(origin)
    if parsed.scheme not in ['http','https'] or not parsed.netloc or parsed.path or parsed.query or parsed.fragment or parsed.username or parsed.password:raise ValueError('Origin must be plain http(s) scheme + host, without credentials or a path')
    pages=manifest['pages']
    if not 1<=len(pages)<=100:raise ValueError('Manifest must contain 1–100 explicit pages')
    report={'version':VERSION,'at_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'origin':origin,'checks':[],'responses':[],'unchecked':['robots.txt rules','JavaScript and rendered content','search engine indexing and selected canonical','content quality, demand, rankings and traffic','user-agent-specific X-Robots directives other than plain generic noindex/none'],'limits':{'pages':100,'sitemap_locations':30,'bytes_per_response':LIMIT,'timeout_seconds':15,'redirects_followed':False}}
    cache={}
    def check(code,url,ok,observed,expected):report['checks'].append({'code':code,'url':url,'passed':bool(ok),'observed':observed,'expected':expected})
    def absolute(path):
        u=urljoin(origin+'/',path);p=urlsplit(u)
        if p.scheme+'://'+p.netloc!=origin or p.username or p.password or p.fragment:raise ValueError('Refusing non-origin URL or fragment: '+u)
        return u
    def request(path):
        u=absolute(path)
        if u in cache:return cache[u]
        p=urlsplit(u);C=http.client.HTTPSConnection if p.scheme=='https' else http.client.HTTPConnection;c=C(p.hostname,p.port,timeout=15)
        row={'url':u};body=b''
        try:
            c.request('GET',p.path+('?' + p.query if p.query else ''),headers={'User-Agent':'Neaptide-Release-Check/1.0','Accept-Encoding':'identity'})
            res=c.getresponse();body=res.read(LIMIT+1)
            if len(body)>LIMIT:raise ValueError('Response exceeded byte limit')
            row.update(status=res.status,content_type=res.getheader('Content-Type',''),x_robots_tag=res.getheader('X-Robots-Tag',''),location=res.getheader('Location'))
        except Exception as e:row['error']=type(e).__name__+': '+str(e)
        finally:c.close()
        name=f'response-{len(report["responses"])+1:03d}.body';(out/name).write_bytes(body)
        row.update(body_file=name,bytes=len(body),sha256=hashlib.sha256(body).hexdigest());report['responses'].append(row);cache[u]=(row,body);return row,body
    for page in pages:
        u=absolute(page['path']);row,raw=request(u);ok=row.get('status')==200 and not row.get('error');check('PAGE_STATUS',u,ok,row.get('status',row.get('error')),200)
        if not ok:continue
        html='text/html' in row.get('content_type','');check('HTML',u,html,row.get('content_type'),'text/html')
        if not html:continue
        p=Page();p.feed(raw.decode('utf-8','replace'))
        canonical=page['canonical'].replace('{origin}',origin);check('CANONICAL',u,p.canonical==[canonical],p.canonical,[canonical])
        values=p.robots+[row.get('x_robots_tag','')];tokens=[t.strip().lower() for v in values for t in v.split(',')]
        check('NOINDEX',u,not any(t in ['noindex','none'] for t in tokens),values,'No generic/Googlebot meta or plain generic X-Robots noindex/none')
        check('TITLE',u,len(p.titles)==1 and bool(p.titles[0].strip()),p.titles,'One nonempty title, per this release contract')
        check('H1',u,p.h1>=1,p.h1,'At least one H1 in response HTML, per this release contract')
        wanted=sorted((k,v.replace('{origin}',origin)) for k,v in page.get('hreflang',{}).items())
        check('HREFLANG',u,sorted(p.alternates)==wanted,p.alternates,wanted)
    sm=absolute(manifest['sitemap']);row,raw=request(sm);check('SITEMAP_STATUS',sm,row.get('status')==200 and not row.get('error'),row.get('status',row.get('error')),200)
    try:
        root=ET.fromstring(raw);ns='{http://www.sitemaps.org/schemas/sitemap/0.9}'
        if root.tag!=ns+'urlset':raise ValueError('Only a sitemap urlset is supported; no sitemap indexes')
        urls=[x.text for x in root.findall(ns+'url/'+ns+'loc')]
        if len(urls)>30 or any(not u for u in urls):raise ValueError('Sitemap empty loc or more than 30 locations')
        check('SITEMAP_XML',sm,True,len(urls),'Valid urlset, at most 30 nonempty loc elements')
        expected=[absolute(p['path']) for p in pages];check('SITEMAP_MEMBERSHIP',sm,set(expected)<=set(urls),urls,expected)
        for u in dict.fromkeys(urls):
            target,_=request(u);check('SITEMAP_TARGET_STATUS',u,target.get('status')==200 and not target.get('error'),target.get('status',target.get('error')),200)
    except (ET.ParseError,ValueError,TypeError) as e:check('SITEMAP_XML',sm,False,str(e),'Supported same-origin urlset')
    report['findings']=[c for c in report['checks'] if not c['passed']];report['passed']=not report['findings']
    (out/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n');return report

def main():
    ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('--manifest',required=True);ap.add_argument('--origin',required=True);ap.add_argument('--out',required=True);a=ap.parse_args()
    try:
        report=run(json.loads(Path(a.manifest).read_text()),a.origin,a.out)
    except (OSError,ValueError,KeyError,TypeError) as e:
        print('INPUT/RUN ERROR:',e,file=sys.stderr);return 2
    print(json.dumps({'passed':report['passed'],'checks':len(report['checks']),'findings':len(report['findings']),'report':str(Path(a.out)/'report.json')}));return 0 if report['passed'] else 1
if __name__=='__main__':sys.exit(main())
