"""Current-person geometry -> ROI condition; notification and display are separate."""
import math
import uuid


def distance(a,b):
    return math.hypot(a[0]-b[0],a[1]-b[1])


def angle(a,b):
    return math.degrees(math.atan2(abs(b[0]-a[0]),abs(b[1]-a[1])))


def features(person, config):
    box=person['bbox'];width,height=box[2]-box[0],box[3]-box[1]
    result=dict(query=person['query'],track_id=person.get('track_id'),score=person['score'],bbox=box,
        eligible=False,observable=False,candidate=False,hit=False,rule=None,reasons=[],torso=None,
        legs=[],thighs=[],standing_support=False,joint_states=[],required_available=[])
    if (not all(math.isfinite(x) for x in box) or width<=0 or height<=0 or
            not math.isfinite(person['score'])):
        result['reasons']=['INVALID_PERSON'];return result
    if person['score']<config['person_score']:
        result['reasons']=['PERSON_SCORE'];return result
    result['eligible']=True;scale=math.hypot(width,height)
    points=[]
    for k in person['keypoints']:
        state='INVALID' if len(k)!=3 or not all(math.isfinite(v) for v in k) else 'VALID' if k[2]>=config['joint_score'] else 'LOW_SCORE'
        result['joint_states'].append(state);points.append(k[:2] if state=='VALID' else None)
    def center(indices):
        if not all(points[i] is not None for i in indices):return None
        return tuple(sum(points[i][axis] for i in indices)/len(indices) for axis in (0,1))
    torsos=[]
    for side, shoulders, hips in [('mid',(5,6),(11,12)),('left',(5,),(11,)),('right',(6,),(12,))]:
        top,bottom=center(shoulders),center(hips)
        if top is None or bottom is None:continue
        length=distance(top,bottom)/scale
        torsos.append(dict(side=side,angle=angle(top,bottom),length_ratio=length,
            valid=config['torso_length_min_box_diagonal']<=length<=config['torso_length_max_box_diagonal'],
            joints=list(shoulders+hips),top=top,bottom=bottom))
    bilateral=[t['length_ratio'] for t in torsos if t['side']!='mid' and t['valid']]
    if len(bilateral)==2 and max(bilateral)/min(bilateral)>config['bilateral_torso_length_ratio_max']:
        result['reasons']=['BILATERAL_TORSO_INCONSISTENT'];return result
    torso=next((t for t in torsos if t['valid']),None)
    result['torso']=torso
    if torso is None:
        result['reasons']=['TORSO_GEOMETRY_UNAVAILABLE'];return result
    result['candidate']=torso['angle']>=config['torso_down_deg']
    for side,h,k,a in [('left',11,13,15),('right',12,14,16)]:
        for endpoint,kind in [(a,'legs'),(k,'thighs')]:
            if points[h] is None or points[endpoint] is None:continue
            length=distance(points[h],points[endpoint])/scale
            if not config['limb_length_min_box_diagonal']<=length<=config['limb_length_max_box_diagonal']:continue
            value=dict(side=side,angle=angle(points[h],points[endpoint]),length_ratio=length,
                drop_box_height=(points[endpoint][1]-points[h][1])/height,joints=[h,endpoint])
            result[kind].append(value)
    result['standing_support']=any(x['angle']<=config['supported_leg_max_deg'] and
        x['drop_box_height']>=config['supported_ankle_drop_box_height'] for x in result['legs'])
    result['box_aspect']=width/height
    result['observable']=bool(result['legs'] or result['thighs'])
    if not result['observable']:
        result['reasons']=['LOWER_BODY_UNAVAILABLE'];return result
    if not result['candidate']:
        result['reasons']=['TORSO_NOT_DOWN'];return result
    if result['standing_support']:
        result['reasons']=['LOCAL_STANDING_SUPPORT'];return result
    if any(x['angle']>=config['leg_down_deg'] for x in result['legs']):
        result['hit']=True;result['rule']='F1_FULL_LEG'
    elif not result['legs'] and any(x['angle']>=config['thigh_only_down_deg'] for x in result['thighs']) and width/height>=config['thigh_only_box_aspect']:
        result['hit']=True;result['rule']='F2_THIGH_ONLY'
    else:result['reasons']=['LOWER_BODY_NOT_DOWN']
    return result


def overlap(a,b):
    inter=max(0,min(a[2],b[2])-max(a[0],b[0]))*max(0,min(a[3],b[3])-max(a[1],b[1]))
    union=(a[2]-a[0])*(a[3]-a[1])+(b[2]-b[0])*(b[3]-b[1])-inter
    return inter/union if union>0 else 0.


class SpatialState:
    def __init__(self, config, identity):
        self.config=config;self.identity=identity;self.last_pts=None;self.last_hit=None
        self.last_issued=None;self.latched=False;self.display_memory=[];self.counter=0

    def evaluate(self, pts, people, *, monotonic_now):
        if not math.isfinite(pts) or (self.last_pts is not None and pts<=self.last_pts):
            raise ValueError('Non-increasing input time; new run requires independent state')
        if not math.isfinite(monotonic_now) or (self.last_issued is not None and monotonic_now<self.last_issued):
            raise ValueError('Monotonic cooldown clock regressed')
        self.last_pts=pts;c=self.config
        rearmed=self.latched and self.last_hit is not None and pts-self.last_hit>=c['notification_rearm_seconds']
        if rearmed:self.latched=False
        hit=any(p['hit'] for p in people)
        if hit:self.last_hit=pts
        event=None;blocked=None
        if hit:
            if self.last_issued is None or monotonic_now-self.last_issued>=c['cooldown_seconds']:
                self.counter+=1;self.last_issued=monotonic_now;self.latched=True
                event=dict(id=str(uuid.uuid5(uuid.NAMESPACE_URL,f'offline-fall:{self.identity}:{self.counter}')),
                    time=pts,queries=[p['query'] for p in people if p['hit']],mock=True)
            else:blocked='COOLDOWN'
        memory=[m for m in self.display_memory if pts-m['hit_pts']<=c['display_hold_seconds']+1e-9]
        matches={}
        for i,p in enumerate(people):
            if not p['eligible']:continue
            options=[]
            for j,m in enumerate(memory):
                a,b=p['bbox'],m['bbox'];center_a=((a[0]+a[2])/2,(a[1]+a[3])/2);center_b=((b[0]+b[2])/2,(b[1]+b[3])/2)
                normalized=distance(center_a,center_b)/max(math.hypot(b[2]-b[0],b[3]-b[1]),1.)
                if overlap(a,b)>=c['display_match_iou_min'] and normalized<=c['display_match_distance_max']:options.append(j)
            if len(options)==1:matches[i]=options[0]
        unique={i:j for i,j in matches.items() if list(matches.values()).count(j)==1}
        colors=[];new_memory=[];used=set()
        for i,p in enumerate(people):
            color=None
            if p['hit']:
                color='RED_CURRENT';new_memory.append(dict(bbox=p['bbox'],hit_pts=pts))
                if i in unique:used.add(unique[i])
            elif i in unique:
                color='YELLOW_HOLD';used.add(unique[i]);new_memory.append(memory[unique[i]])
            elif p['candidate']:color='BLUE_CANDIDATE'
            if color:colors.append(dict(query=p['query'],bbox=p['bbox'],state=color))
        new_memory.extend(m for j,m in enumerate(memory) if j not in used)
        # Unmatched spatial memory is retained only to its original finite expiry.
        self.display_memory=new_memory[-64:]
        display=hit or (self.last_hit is not None and pts-self.last_hit<=c['display_hold_seconds']+1e-9)
        return dict(hit=hit,observable=any(p['observable'] for p in people),event=event,
            display=display,display_hold=display and not hit,colors=colors,
            association_uncertain=display and not any(x['state'] in ('RED_CURRENT','YELLOW_HOLD') for x in colors),
            rearmed=rearmed,latched=self.latched,last_hit=self.last_hit,last_issued=self.last_issued,
            blocked=blocked,notification_scope='one ROI aggregate; channel/FALL cooldown')
