#!/usr/bin/env python3
"""离线验证公开最小披露包。只用标准库，不联网、不写文件。"""
import argparse, hashlib, json, re, sys
from pathlib import Path

FILES = {'index.html','README.md','records.jsonl','report_labels.json','rules.json','version.json','verify.py','manifest.json','manifest.sha256'}
CORE = FILES - {'manifest.json','manifest.sha256'}
FIELDS = {'record_id','batch','platform','qid','qtype','question','sampled_at','model_name','model_version','temperature','top_p','network_enabled','literal_mention','answer_projection','disclosure','basis','independent_identity_label'}
LABEL_FIELDS = {'report_record_id','round','platform','qid','original_label','correct_identity','basis','historical_reviewer','historical_label_time','current_independent_review','sampling_record_id'}
UNKNOWN = ('sampled_at','model_name','model_version','temperature','top_p','network_enabled','independent_identity_label')
PATTERNS = {'private_path':r'/(?:Users|home|private|var)/[\w.-]+','email':r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}','mobile':r'(?<!\w)1[3-9]\d{9}(?!\w)','landline':r'(?<!\d)0\d{2,3}[- ]\d{7,8}(?!\d)','credential':r'(?i)(?:sk-[A-Za-z0-9]{15,}|Bearer\s+[A-Za-z0-9._-]{12,}|AKIA[A-Z0-9]{16})'}

def digest(p): return hashlib.sha256(p.read_bytes()).hexdigest()
def run(root):
    errors=[]
    def check(ok, code):
        if not ok: errors.append(code)
    actual={p.relative_to(root).as_posix() for p in root.rglob('*')}
    check(actual==FILES,'whitelist_mismatch')
    check(not any(p.is_symlink() for p in root.rglob('*')),'symlink_forbidden')
    check((root/'manifest.sha256').read_text().strip()==digest(root/'manifest.json'),'manifest_digest_mismatch')
    manifest=json.loads((root/'manifest.json').read_text())
    check(set(manifest['files'])==CORE,'manifest_coverage_mismatch')
    for name in CORE:
        entry=manifest['files'].get(name,{})
        p=root/name
        check(p.is_file() and p.stat().st_size==entry.get('bytes') and digest(p)==entry.get('sha256'),'file_bytes_mismatch:'+name)
    hash_ok=not errors
    findings=[]
    for name in FILES:
        p=root/name
        if p.exists():
            text=p.read_text(encoding='utf-8')
            for key,pattern in PATTERNS.items():
                if re.search(pattern,text): findings.append({'file':name,'kind':key})
    check(not findings,'privacy_pattern_match')
    rules=json.loads((root/'rules.json').read_text()); target=rules['target']; expected=rules['expected']
    rows=[json.loads(s) for s in (root/'records.jsonl').read_text().splitlines() if s]
    check(len(rows)==expected['records']==150,'record_count_mismatch')
    check(len({r['record_id'] for r in rows})==150,'record_id_duplicate')
    check({r['record_id'] for r in rows}=={f'P{i:04}' for i in range(1,151)},'record_id_coverage')
    combos={(r['batch'],r['platform'],r['qid']) for r in rows}
    required={(b,p,f'Q{i:02}') for b in ('B1','B2') for p in ('doubao','deepseek','qianwen') for i in range(1,26)}
    check(combos==required and len(combos)==len(rows),'sample_matrix_mismatch')
    for r in rows:
        rid=r['record_id']
        check(set(r)==FIELDS,'record_fields:'+rid)
        check(r['question']==rules['question_allowlist'].get(r['qid']),'question_not_allowed:'+rid)
        named=target in r['question']
        check(named==(int(r['qid'][1:])>=21) and r['qtype']==('named' if named else 'natural'),'qtype_mismatch:'+rid)
        check(all(r[k] is None for k in UNKNOWN),'unknown_metadata_changed:'+rid)
        check(type(r['literal_mention']) is bool and r['answer_projection']==(target if r['literal_mention'] else None),'projection_mismatch:'+rid)
        check(r['disclosure']=='target-token-only','disclosure_changed:'+rid)
        check(r['basis']==('完整字面命中；仅展示目标词' if r['literal_mention'] else '原回答字面检查未命中；全文未披露，读者不能独立核对阴性'),'basis_mismatch:'+rid)
    natural=[r for r in rows if r['qtype']=='natural']
    hits=sum(r['answer_projection']==target for r in natural)
    named_n=sum(r['qtype']=='named' for r in rows)
    check((hits,len(natural),named_n)==(expected['natural_literal'],expected['natural_denominator'],expected['named_denominator']),'natural_metric_mismatch')
    platforms={p:dict(n=sum(r['platform']==p for r in natural),hit=sum(r['platform']==p and r['answer_projection']==target for r in natural)) for p in ('doubao','deepseek','qianwen')}
    check(platforms==expected['platform_natural'],'platform_metric_mismatch')
    labels=json.loads((root/'report_labels.json').read_text()); lr=labels['rows']
    check(labels['round_to_batch_mapping'] is None,'unverified_mapping_added')
    check(len(lr)==expected['report_denominator']==30,'report_count_mismatch')
    check({(r['round'],r['platform'],r['qid']) for r in lr}=={(b,p,f'Q{i:02}') for b in ('R1','R2') for p in ('doubao','deepseek','qianwen') for i in range(21,26)},'report_matrix_mismatch')
    check({r['report_record_id'] for r in lr}=={f'L{i:03}' for i in range(1,31)},'report_id_mismatch')
    for r in lr:
        check(set(r)==LABEL_FIELDS,'label_fields:'+r['report_record_id'])
        check(r['original_label'] in ('正确','未正确指向') and type(r['correct_identity']) is bool and r['correct_identity']==(r['original_label']=='正确'),'label_inconsistent:'+r['report_record_id'])
        check(all(r[k] is None for k in ('historical_reviewer','historical_label_time','current_independent_review','sampling_record_id')),'historical_metadata_invented')
        check(r['basis']==f"报告第09页：{r['platform']} / {r['round']} / {r['qid']} 单元格原标签为“{r['original_label']}”；未作独立语义复核",'label_basis_mismatch')
    positive=sum(r['original_label']=='正确' for r in lr)
    check(positive==expected['report_positive'],'report_metric_mismatch')
    return dict(ok=not errors,hash_ok=hash_ok,privacy_pattern_findings=findings,whitelist_files=len(FILES),errors=errors,counts=dict(records=len(rows),natural_literal=hits,natural_denominator=len(natural),named_denominator=named_n,platform_natural=platforms,report_label_positive=positive,report_label_denominator=len(lr)),limits=['37/120是披露字段字面计数；未披露全文，不能独立复核阴性或推荐含义。','4/30是报告原判定汇总，可计算但判断待核；未建立报告轮次到采样记录的对应。','SHA-256只核完整性；不认证来源、采样时间、语义判断或实验真实性。','隐私正则扫描不等于完整隐私审计；字段白名单和人工题面审查仍必需。'])

if __name__=='__main__':
    parser=argparse.ArgumentParser(); parser.add_argument('--root',type=Path,default=Path(__file__).resolve().parent); args=parser.parse_args()
    try: result=run(args.root)
    except Exception as exc: result=dict(ok=False,hash_ok=False,errors=['invalid_package:'+type(exc).__name__])
    print(json.dumps(result,ensure_ascii=False,indent=2))
    sys.exit(0 if result['ok'] else 1)
