#!/usr/bin/env python3
"""Deterministic behavioural view of timestamped transcripts; raw data is never edited.
Grouping by evidence channel avoids scheduler-dependent read/flush interleaving.
"""
import collections,json,pathlib,re

ANSI=re.compile(r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[=>])')

def events(path):
 out=[]
 for line in pathlib.Path(path).read_text().splitlines():
  m=re.match(r'^(\S+) \+([0-9.]+) (\S+) (.*)$',line)
  if not m:raise ValueError(f'Unparsed transcript line: {line[:150]}')
  out.append(dict(utc=m[1],t=float(m[2]),kind=m[3],data=json.loads(m[4])))
 return out

class Normalizer:
 def __init__(self,ev):
  self.pidmap={};self.tempm={}
  # Stable identities in spawn/exec order preserve process counts and relationships.
  for e in ev:
   if e['kind']=='SPAWN':self.pid(e['data']['pid'])
   if e['kind']=='TRANSPORT_TRACE' and 'pid' in e['data']:self.pid(e['data']['pid'])
 def pid(self,p):
  if p not in self.pidmap:self.pidmap[p]='<PID'+str(len(self.pidmap)+1)+'>'
  return self.pidmap[p]
 def text(self,s):
  s=ANSI.sub('',s)
  s=re.sub(r'/tmp/lead-32913-r1/evidence-capture(?:-[23])?/(?:runs-|same-revision-|helper-branches-|regressions-)[^/\s\"\']+(?:/[^/\s\"\']+)?/(?:before|after|head-a|head-b)', '<RUN>',s)
  s=re.sub(r'/tmp/lead-32913-r1/evidence-capture(?:-[23])?', '<SCRATCH>',s)
  s=re.sub(r'127\.0\.0\.1:\d+', '127.0.0.1:<PORT>',s)
  s=re.sub(r'\bport \d+', 'port <PORT>',s)
  s=re.sub(r'\.staging-[A-Za-z0-9.]+', '.staging-<RANDOM>',s)
  def temp(m):
   v=m[0]
   if v not in self.tempm:self.tempm[v]='tmp.<TEMP'+str(len(self.tempm)+1)+'>'
   return self.tempm[v]
  s=re.sub(r'\btmp\.[A-Za-z0-9]{6,}',temp,s)
  s=re.sub(r'\b\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d(?:\.\d+)?(?:\+00:00|Z)?', '<UTC>',s)
  s=re.sub(r'\b\d\d:\d\d:\d\d(?:\.\d+)?\b','<CLOCK>',s)
  s=re.sub(r'(<RUN>/[^\s:]*\.(?:ps1|sh|mjs)):\d+(?::\d+)?',r'\1:<LINE>',s)
  s=re.sub(r'(line |char:)\d+',r'\1<LINE>',s)
  s=re.sub(r'(/bin/(?:dash|bash|sh)): \d+:',r'\1: <LINE>:',s)
  s=re.sub(r'after \d+(?:\.\d+)? ms:', 'after <DURATION>:',s)
  return s
 def obj(self,x,key=''):
  if isinstance(x,dict):
   out={}
   for k,v in x.items():
    if k in ['t','utc','request_t','elapsed_seconds','elapsed','sha','side','original_source_sha256','executed_source_sha256']:continue
    if k=='pid':out[k]=self.pid(v)
    elif k=='tracked_downloader_pids':out[k]=[self.pid(p) for p in v]
    elif k=='bytes' and x.get('path','').endswith('/runtime/launchers'):out[k]='<PATH_DERIVED_BYTES>'
    elif k=='bytes' and '/.cache/powershell/' in x.get('path',''):out[k]='<POWERSHELL_CACHE_BYTES>'
    else:out[k]=self.obj(v,k)
   return out
  if isinstance(x,list):return [self.obj(v,key) for v in x]
  if isinstance(x,str):return self.text(x)
  return x

def ps_waits(ev):
 """Measured Start-Sleep -> return trace gaps; retain cancellation explicitly."""
 out=[];start=None
 for e in ev:
  if e['kind']!='OUTPUT':continue
  s=e['data']
  if '>>>> Start-Sleep -Seconds' in s:start=e['t']
  if start is not None and 'return  >>>> $true' in s:
   out.append({'completed':True,'seconds':round(e['t']-start,1)});start=None
 if start is not None:out.append({'completed':False,'interrupted':True})
 return out

def user_output(raw,n):
 s=n.text(raw).replace('\r\n','\n').replace('\r','\n')
 # PowerShell traces contain source text, including strings that were not emitted.
 s=re.sub(r'DEBUG:[^\n]*','',s)
 out=[];progress=[]
 for line in s.splitlines():
  line=line.strip()
  if not line:continue
  if re.match(r'(?:Line\s*\||\d+\s*\||\|\s*[~^]+\s*$)',line):continue
  if re.match(r'^\|',line):line=line.lstrip('|').strip()
  if not line:continue
  if re.search(r'[■·█▀▄□]|━|░|▓|▒',line) or re.match(r'^\s*\d+(?:\.\d+)?%',line):
   progress.extend(int(x) for x in re.findall(r'(\d+)%',line));continue
  # Native wget progress counters and speed/elapsed fields are scheduling noise.
  if re.match(r'^\d+[Kk]\s+[. ]+\d+%',line):continue
  line=re.sub(r'\([\d.]+ [KMGT]?B/s\)', '(<RATE>)',line)
  line=re.sub(r'\b[\d.]+[KMGT]?B/s\b', '<RATE>',line)
  line=re.sub(r'\b[\d.]+[KMGT]?\s*=\s*[\d.]+s\b','<TRANSFER_TIME>',line)
  line=re.sub(r'\[[\d.]+[smh]\]', '[<DURATION>]',line)
  if re.match(r'^--<UTC>--',line):line=re.sub(r'^--<UTC>--\s*','',line)
  # Error location frames differ when helper source grows; diagnostics remain.
  if re.match(r'^[A-Za-z][\w.-]*: <RUN>/.*:<LINE>$',line):line=line.split(': ',1)[0]+': <SOURCE>'
  if re.search(r'\b(?:Duration|Start at)\b',line):continue
  if 'scripts/' in line or line.startswith(('✓ ','× ')):
   line=re.sub(r'(?<!HTTP) \d+(?:\.\d+)?(?:ms|s)(?=\s|$)', ' <DURATION>',line)
  line=re.sub(r'(\.(?:test\.)?(?:ts|tsx|mjs|js)):\d+(?::\d+)?',r'\1:<LINE>',line)
  out.append(line)
 return out,({'observed':True,'minimum_percent':min(progress),'maximum_percent':max(progress)} if progress else None)

def normalize(path):
 ev=events(path);n=Normalizer(ev);manifest=next(e['data'] for e in ev if e['kind']=='MANIFEST')
 # Compare behaviour, not source metadata, command source hashes, or source line traces.
 setup={k:manifest[k] for k in ['id','case','shape','environment','selected','selected_transport_version'] if k in manifest}
 output=''.join(e['data'] for e in ev if e['kind']=='OUTPUT')
 lines,progress=user_output(output,n)
 result=next(e['data'] for e in reversed(ev) if e['kind']=='RESULT')
 sections=[('SETUP',n.obj(setup))]
 # Full server request sequence retains .NET native attempts. Granularity is 0.1s.
 req=[e['data'] for e in ev if e['kind']=='SERVER' and e['data'].get('event')=='request']
 for r in req:sections.append(('REQUEST',n.obj(r)))
 # Backoff is measured separately, without startup time or body-transfer duration.
 if manifest.get('selected',{}).get('surface')=='P':
  waits=ps_waits(ev)
 else:
  waits=[]
  trace=[e['data'] for e in ev if e['kind']=='TRANSPORT_TRACE']
  if manifest.get('selected',{}).get('surface')=='L':
   # Start a retry gap at response cancellation/rejection, not initial fetch entry.
   failure=None
   for t in trace:
    if t.get('event') in ['body_cancel','fetch_rejected']:failure=t['t']
    elif t.get('event')=='fetch_start' and failure is not None:
     waits.append({'completed':True,'seconds':round(t['t']-failure,1)});failure=None
  else:
   # HTTP request intervals exclude wrapper process startup before the first call.
   for a,b in zip(req,req[1:]):
    if a['path']==b['path'] and b['request_t']-a['request_t']>=0.3:
     waits.append({'completed':True,'seconds':round(b['request_t']-a['request_t'],1)})
   # Closed-port cases have no HTTP event; captured execs remain the timing source.
   if not req:
    calls=[t for t in trace if t.get('event','').startswith('exec ')]
    for a,b in zip(calls,calls[1:]):
     if a.get('argv')==b.get('argv') and b['t']-a['t']>=0.3:
      waits.append({'completed':True,'seconds':round(b['t']-a['t'],1)})
 for w in waits:sections.append(('WAIT',w))
 for e in ev:
  if e['kind'] in ['SPAWN','ACTION','TIMEOUT','DRIVER_ERROR','SERVER_STATE_BEFORE_DRIVER_CLEANUP']:
   sections.append((e['kind'],n.obj(e['data'])))
 for e in ev:
  if e['kind']=='TRANSPORT_TRACE':
   data=e['data']
   if data.get('event','').startswith('exec '):
    # Command flags belong to raw source/trace; compare calls, URLs and destinations.
    argv=data['argv'];value={k:data[k] for k in ['event','pid']}
    value['urls']=[x for x in argv if x.startswith('http')]
    value['destination']=argv[argv.index('-o')+1] if '-o' in argv else argv[argv.index('-O')+1] if '-O' in argv else None
   else:value=data
   sections.append(('TRANSPORT',n.obj(value)))
 if manifest.get('selected',{}).get('surface')=='P':
  ops=[]
  for m in re.finditer(r'>>>> ([^\r\n]*)',output):
   s=m[1]
   if s.startswith('Invoke-WebRequest -Uri'):ops.append('IWR')
   elif re.match(r'^(?:\$(?:\{[^}]+\}|[\w:]+)\s*=\s*)?Invoke-RestMethod\s+-Uri\b',s):ops.append('IRM')
   elif s.startswith('$response = $client.GetAsync'):ops.append('HTTPCLIENT_GET')
   elif s.startswith('Wait-Retry 0 $attempt'):ops.append('NETWORK_CATCH_WAIT_RETRY_0')
   elif s.startswith('-not (Wait-Retry ([int]$_.Exception.Response.StatusCode)'):ops.append('CMDLET_CATCH_WAIT_RETRY')
   elif s.startswith('Start-Sleep -Seconds'):ops.append('START_SLEEP')
   elif re.match(r'\$(client|response|source|file)\.Dispose\(',s):ops.append(s.split('(')[0])
  sections.append(('EXECUTED_OPERATIONS',ops))
 for line in lines:
  try:
   value=json.loads(line)
   if isinstance(value,dict):line=n.obj(value)
  except ValueError:pass
  sections.append(('OUTPUT',line))
 if progress:sections.append(('PROGRESS',progress))
 sections.append(('RESULT',n.obj(result)))
 return ''.join(kind+' '+json.dumps(value,ensure_ascii=False,sort_keys=True)+'\n' for kind,value in sections)
