"""Deterministic discrete-event inspection cell with finite admitted WIP.
All job randomness is drawn before simulation for matched policy comparisons.
Time is seconds. Breaks are full-duration and deferred until an active job finishes.
"""
from dataclasses import dataclass
import heapq, itertools, math
import numpy as np

@dataclass(frozen=True)
class Job:
    id:int
    arrival_s:float
    service_s:tuple
    estimated_service_s:float
    kind:str='reference'

def simulate(jobs,staff=1,capacity=8,horizon_s=28800,rework_delay_s=60,breaks=(),policy='FIFO',record_events=False):
    if not all(math.isfinite(v) for v in (staff,capacity,horizon_s,rework_delay_s)) or staff<1 or int(staff)!=staff or capacity<staff or int(capacity)!=capacity or horizon_s<=0 or rework_delay_s<0:
        raise ValueError('Invalid staffing, WIP limit, horizon or rework delay')
    if policy not in ('FIFO','SPT'):raise ValueError('Unknown dispatch policy')
    staff,capacity=int(staff),int(capacity)
    if len({j.id for j in jobs})!=len(jobs):raise ValueError('Job IDs must be unique')
    for j in jobs:
        if j.arrival_s<0 or not j.service_s or min(j.service_s)<=0 or j.estimated_service_s<=0 or not all(math.isfinite(v) for v in (j.arrival_s,j.estimated_service_s,*j.service_s)):
            raise ValueError('Finite positive service and nonnegative arrivals required')
    eventq=[];counter=itertools.count()
    def push(t,priority,kind,payload):heapq.heappush(eventq,(float(t),priority,next(counter),kind,payload))
    for j in jobs:
        if j.arrival_s<=horizon_s:push(j.arrival_s,3,'arrival',j)
    for worker,start,duration in breaks:
        if not all(math.isfinite(v) for v in (worker,start,duration)) or int(worker)!=worker or worker<0 or worker>=staff or start<0 or duration<=0:raise ValueError('Invalid break')
        push(start,1,'break',(int(worker),duration))
    workers=[dict(job=None,on_break=False,pending=0.) for _ in range(staff)]
    records={};queue=[];inside=0;maxinside=0;now=0.;last=0.;areas=dict(wip=0.,waiting=0.,busy=0.,breaks=0.)
    events=[];completed=0;rejected=0;visits=0;peakqueue=0
    def integrate(t):
        nonlocal last
        dt=t-last;areas['wip']+=inside*dt;areas['waiting']+=len(queue)*dt
        areas['busy']+=sum(w['job'] is not None for w in workers)*dt
        areas['breaks']+=sum(w['on_break'] for w in workers)*dt;last=t
    def begin_break(worker,duration):
        w=workers[worker];w['on_break']=True;push(now+duration,4,'resume',worker)
    def dispatch():
        nonlocal visits
        for i,w in enumerate(workers):
            if w['job'] is not None or w['on_break']:continue
            if w['pending']:
                duration=w['pending'];w['pending']=0.;begin_break(i,duration);continue
            if not queue:continue
            if policy=='FIFO':chosen=min(queue,key=lambda jid:(records[jid]['queue_enter_s'],jid))
            else:chosen=min(queue,key=lambda jid:(records[jid]['job'].estimated_service_s,records[jid]['queue_enter_s'],jid))
            queue.remove(chosen);r=records[chosen];r['waiting_s']+=now-r['queue_enter_s'];r['state']='running'
            r['starts'].append(now);w['job']=chosen;visits+=1
            push(now+r['job'].service_s[r['pass']],0,'complete',i)
    while eventq and eventq[0][0]<=horizon_s:
        now=eventq[0][0];integrate(now)
        # Apply simultaneous events before dispatch. Completion frees capacity before arrivals.
        while eventq and eventq[0][0]==now:
            _,_,_,kind,payload=heapq.heappop(eventq)
            if kind=='arrival':
                j=payload;r=dict(job=j,state='rejected',pass_=0,waiting_s=0.,queue_enter_s=now,starts=[],finished_s=None)
                r['pass']=r.pop('pass_');records[j.id]=r
                if inside>=capacity:rejected+=1
                else:
                    inside+=1;maxinside=max(maxinside,inside);r['state']='waiting';queue.append(j.id)
            elif kind=='complete':
                w=workers[payload];jid=w['job'];w['job']=None;r=records[jid]
                r['pass']+=1
                if r['pass']<len(r['job'].service_s):r['state']='rework';push(now+rework_delay_s,2,'return',jid)
                else:r['state']='completed';r['finished_s']=now;inside-=1;completed+=1
            elif kind=='return':
                r=records[payload];r['state']='waiting';r['queue_enter_s']=now;queue.append(payload)
            elif kind=='break':
                i,duration=payload;w=workers[i]
                if w['job'] is not None or w['on_break']:w['pending']+=duration
                else:begin_break(i,duration)
            elif kind=='resume':workers[payload]['on_break']=False
            if record_events:events.append(dict(time_s=now,event=kind,inside=inside,waiting=len(queue),busy=sum(w['job'] is not None for w in workers),on_break=sum(w['on_break'] for w in workers)))
        dispatch();peakqueue=max(peakqueue,len(queue))
        if record_events:events.append(dict(time_s=now,event='dispatch',inside=inside,waiting=len(queue),busy=sum(w['job'] is not None for w in workers),on_break=sum(w['on_break'] for w in workers)))
    integrate(horizon_s)
    output=[]
    for jid,r in sorted(records.items()):
        j=r['job'];wait=r['waiting_s']+(horizon_s-r['queue_enter_s'] if r['state']=='waiting' else 0)
        output.append(dict(job_id=jid,arrival_s=j.arrival_s,kind=j.kind,state=r['state'],waiting_s=wait,
                           finish_s=r['finished_s'],sojourn_s=(r['finished_s'] or horizon_s)-j.arrival_s if r['state']!='rejected' else None,
                           starts=r['starts'],required_visits=len(j.service_s),completed_visits=r['pass']))
    complete=[r for r in output if r['state']=='completed'];unfinished=[r for r in output if r['state'] not in ('completed','rejected')]
    tail=lambda vals:float(np.quantile(vals,.95)) if vals else None
    summary=dict(offered=len(output),completed=completed,rejected=rejected,unfinished=len(unfinished),admitted=len(output)-rejected,
      completed_wait_mean_s=float(np.mean([r['waiting_s'] for r in complete])) if complete else None,
      completed_wait_p95_s=tail([r['waiting_s'] for r in complete]),
      unfinished_wait_p95_s=tail([r['waiting_s'] for r in unfinished]),
      all_admitted_wait_to_horizon_p95_s=tail([r['waiting_s'] for r in output if r['state']!='rejected']),
      average_wip=areas['wip']/horizon_s,average_waiting_jobs=areas['waiting']/horizon_s,
      gross_busy_fraction=areas['busy']/(staff*horizon_s),break_seconds=areas['breaks'],maximum_inside=maxinside,maximum_waiting=peakqueue,
      conservation_residual=len(output)-completed-rejected-len(unfinished),
      wait_area_residual_s=areas['waiting']-sum(r['waiting_s'] for r in output),
      wip_area_residual_s=areas['wip']-sum(r['sojourn_s'] for r in output if r['state']!='rejected'),service_starts=visits)
    return dict(summary=summary,jobs=output,events=events,areas=areas)

def generate(seed,segments,service_mean_s=110,service_cv=.45,rework_probability=.12,max_visits=3):
    """Piecewise-Poisson arrivals and lognormal service; class estimate never sees draw."""
    if not all(math.isfinite(v) for v in (service_mean_s,service_cv,rework_probability,max_visits)) or service_mean_s<=0 or service_cv<0 or not 0<=rework_probability<=1 or max_visits<1 or int(max_visits)!=max_visits:raise ValueError('Invalid workload inputs')
    rng=np.random.default_rng(seed);arrivals=[]
    previous_end=0
    for start,end,rate_per_hour in segments:
        if not all(math.isfinite(v) for v in (start,end,rate_per_hour)) or start<previous_end or end<=start or rate_per_hour<0:raise ValueError('Invalid arrival segment')
        previous_end=end
        if rate_per_hour==0:continue
        t=start
        while True:
            t+=rng.exponential(3600/rate_per_hour)
            if t>=end:break
            arrivals.append(float(t))
    sigma=math.sqrt(math.log1p(service_cv**2));jobs=[]
    for i,t in enumerate(sorted(arrivals)):
        # 70% quick checks, 30% detailed; weighted multiplier is exactly 1.
        quick=rng.random()<.7;factor=.7 if quick else 1.7
        mean=service_mean_s*factor;mu=math.log(mean)-sigma*sigma/2
        services=[float(rng.lognormal(mu,sigma))]
        while len(services)<max_visits and rng.random()<rework_probability:
            services.append(float(rng.lognormal(mu,sigma)))
        jobs.append(Job(i,t,tuple(services),mean,'quick' if quick else 'detailed'))
    return jobs
