#!/usr/bin/env python3
"""Curated report redaction and fail-closed package checks.
Not a legal assessment or a guarantee of full anonymization.
Private name mappings may be passed with --terms; mappings are never exported.
Logs contain categories and counts, never the matching personal values.
"""
from __future__ import annotations
from pathlib import Path,PurePosixPath
from collections import Counter
from urllib.parse import unquote
import argparse,base64,hashlib,html,io,json,re,stat,zipfile
RAW_SUFFIXES={'.db','.sqlite','.sqlite3','.bak','.sql','.eml','.emlx','.msg','.jsonl','.repx','.p12','.pem','.key','.pfx','.fdb','.gdb'}
TEXT_SUFFIXES={'.html','.css','.js','.json','.md','.txt','.csv','.svg','.py'}
MEDIA_SUFFIXES={'.webp','.png','.jpg','.jpeg'}
PATTERNS={
 'email':re.compile(r'(?<![\w.+-])[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}(?![\w.-])',re.I),
 'iban':re.compile(r'\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b'),
 'telephone':re.compile(r'(?<!\w)(?:\+|00)(?:43|49|41)[\s()./-]*(?:\d[\s()./-]*){7,12}(?!\d)'),
 'home_path':re.compile(r'(?:'+re.escape('/'+'Users'+'/')+'|'+re.escape('/'+'home'+'/')+r'|[A-Z]:\\Users\\)[^\s<>"\']+',re.I),
 'credential':re.compile(r'\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|AKIA[A-Z0-9]{16})\b'),
 'signed_url':re.compile(r'https?://[^\s<>"\']+[?&](?:token|access_token|signature|sig)=[^\s<>"\']+',re.I),
 'private_link':re.compile(r'https?://[^\s<>"\']*(?:sharepoint\.com|notebooklm\.google|beeper\.com|ticket\.orgacalc|lunis-x\.)[^\s<>"\']*',re.I),
 'private_origin':re.compile(r'https?://(?:github\.com|raw\.githubusercontent\.com)/[^/]+/(?:lunis-endbericht|lunis-istzeit-odoo|edv-hausleitner|hans)(?:/[^\s<>"\']*)?',re.I)
}
DATA_URI=re.compile(r'data:([\w.+/-]+)(?:;[^,\s]*?)?;base64,([A-Za-z0-9+/=]+)')
def normalize(text:str)->str:
 for _ in range(2):text=html.unescape(unquote(text))
 text=re.sub(r'\\u([0-9a-fA-F]{4})',lambda m:chr(int(m[1],16)),text)
 return text

def findings(text:str,terms:dict|None=None,_depth=0)->dict:
 counts=Counter();embedded=[]
 def uri(m):
  mime=m[1].lower()
  if mime.startswith('text/') or mime in ('application/json','image/svg+xml'):
   if _depth>=3:counts['embedded_depth']+=1
   else:
    try:embedded.append(base64.b64decode(m[2],validate=True).decode('utf-8'))
    except (ValueError,UnicodeError):counts['embedded_decode']+=1
  # Binary data is governed by the media allowlist, not considered proof of no PII.
  return '[embedded-asset]'
 text=DATA_URI.sub(uri,text);text=normalize(text)
 for cat,pat in PATTERNS.items():counts[cat]+=len(pat.findall(text))
 for original in terms or {}:
  if original:counts['named_entity']+=len(re.findall(r'(?<!\w)'+re.escape(original)+r'(?!\w)',text,re.I))
 for value in embedded:counts.update(findings(value,terms,_depth+1))
 return {k:v for k,v in counts.items() if v}

def scrub_text(text:str,terms:dict|None=None)->tuple[str,dict]:
 counts=Counter()
 # Preserve HTML/JavaScript escapes. Encoded residual markers fail in findings().
 for original,replacement in sorted((terms or {}).items(),key=lambda x:-len(x[0])):
  if not original:continue
  text,n=re.subn(r'(?<!\w)'+re.escape(original)+r'(?!\w)',replacement,text,flags=re.I);counts['named_entity']+=n
 for cat,pat in PATTERNS.items():text,n=pat.subn('[entfernt]',text);counts[cat]+=n
 return text,{k:v for k,v in counts.items() if v}

def scan_archive(path:Path,records:dict[str,dict],depth=0)->dict:
 """records maps exact ZIP member names to hashes, sizes and binary-review state.
 Nested ZIPs are recursively checked against the same finite member allowlist.
 Unknown extensions, raw formats, changed binaries and unsafe names are rejected.
 """
 errors=[];checked=0
 def inspect(raw:bytes,label:str,level:int):
  nonlocal checked
  if level>2:errors.append({'path':label,'type':'archive_depth'});return
  try:z=zipfile.ZipFile(io.BytesIO(raw))
  except zipfile.BadZipFile:errors.append({'path':label,'type':'invalid_archive'});return
  if len(z.infolist())>1000:errors.append({'path':label,'type':'entry_limit'});return
  seen=set();expanded=0
  for item in z.infolist():
   if item.is_dir():continue
   name=item.filename;p=PurePosixPath(name)
   if name in seen:errors.append({'path':name,'type':'duplicate'});continue
   seen.add(name)
   if p.is_absolute() or '..' in p.parts or '\\' in name or ':' in name:errors.append({'path':name,'type':'unsafe_path'});continue
   record=records.get(name)
   if not record:errors.append({'path':name,'type':'not_allowlisted'});continue
   if p.suffix.lower() in RAW_SUFFIXES:errors.append({'path':name,'type':'raw_format'});continue
   if stat.S_IFMT(item.external_attr>>16)==stat.S_IFLNK:errors.append({'path':name,'type':'symlink'});continue
   expanded+=item.file_size
   if item.file_size>35_000_000 or expanded>250_000_000:errors.append({'path':name,'type':'size_limit'});continue
   if item.flag_bits&1:errors.append({'path':name,'type':'encrypted'});continue
   data=z.read(item);checked+=1
   if len(data)!=record.get('bytes') or hashlib.sha256(data).hexdigest()!=record.get('sha256'):
    errors.append({'path':name,'type':'integrity'});continue
   suffix=p.suffix.lower()
   if suffix in TEXT_SUFFIXES:
    try:hit=findings(data.decode('utf-8-sig'))
    except UnicodeError:errors.append({'path':name,'type':'encoding'});continue
    if hit:errors.append({'path':name,'type':'text_markers','counts':hit})
   elif suffix=='.pdf':
    if not record.get('reviewed_binary'):errors.append({'path':name,'type':'unreviewed_pdf'});continue
    try:
     import fitz
     doc=fitz.open(stream=data,filetype='pdf');t='\n'.join(p.get_text() for p in doc)+str(doc.metadata);hit=findings(t)
     for page in doc:
      for link in page.get_links():
       if link.get('uri','').startswith('file:'):hit['local_pdf_link']=hit.get('local_pdf_link',0)+1
     doc.close()
     if hit:errors.append({'path':name,'type':'pdf_markers','counts':hit})
    except Exception:errors.append({'path':name,'type':'pdf_unreadable'})
   elif suffix in MEDIA_SUFFIXES:
    if not record.get('reviewed_binary'):errors.append({'path':name,'type':'unreviewed_image'})
    try:
     from PIL import Image
     im=Image.open(io.BytesIO(data));im.verify()
    except Exception:errors.append({'path':name,'type':'image_unreadable'})
   elif suffix=='.zip':inspect(data,name,level+1)
   else:errors.append({'path':name,'type':'unsupported_format'})
  z.close()
 inspect(path.read_bytes(),path.name,depth)
 return {'files_checked_including_nested':checked,'status':'PASS' if not errors else 'FAIL','errors':errors,'limits':'Finite allowlist, exact hashes and text/PDF checks. Image visibility must have been reviewed. No legal or full-anonymity guarantee.'}

def main():
 p=argparse.ArgumentParser();p.add_argument('input',type=Path);p.add_argument('--output',type=Path);p.add_argument('--scan',action='store_true');p.add_argument('--terms',type=Path);a=p.parse_args()
 if a.input.suffix.lower() in RAW_SUFFIXES:raise SystemExit('Refused: raw-file category.')
 if a.input.suffix.lower() not in TEXT_SUFFIXES:raise SystemExit('Refused: format requires explicit binary review.')
 terms=json.loads(a.terms.read_text()) if a.terms else {}
 if not isinstance(terms,dict) or any(not isinstance(k,str) or not isinstance(v,str) for k,v in terms.items()):raise SystemExit('Terms must be a text-to-text mapping.')
 text=a.input.read_text(encoding='utf-8-sig')
 if a.scan:
  hits=findings(text,terms);print(json.dumps({'status':'FAIL' if hits else 'PASS','counts':hits}));raise SystemExit(bool(hits))
 if not a.output or a.output.resolve()==a.input.resolve():raise SystemExit('A distinct --output path is required. Originals are never overwritten.')
 clean,counts=scrub_text(text,terms);remaining=findings(clean,terms)
 if remaining:print(json.dumps({'status':'FAIL','counts':remaining}));raise SystemExit(1)
 a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(clean,encoding='utf-8');print(json.dumps({'status':'REDACTED_COPY','replacements':counts,'limits':'Review semantic identity and imagery separately.'}))
if __name__=='__main__':main()
