"""Arbeit UI evidence and pure coordinate candidates; never sends input.

OCR captions establish semantic evidence only. Geometry additionally requires
the current source-bound layout parent, frame and active children. In particular,
NPC world positions and historical OCR regions are never click coordinates.
"""
from __future__ import annotations
from datetime import datetime
import hashlib
import json
import math
import re
import time
from .paths import root
from .vendor.ui_layout import CanvasProfile, Rect, Viewport, resolve_canvas, target_from_rect


def ui_map():
    return json.loads((root() / 'data/arbeit-ui-map.json').read_text(encoding='utf-8'))


def _failure(target_id, reasons, missing=(), **extra):
    return dict(target_id=target_id, status='blocked', reason_codes=list(dict.fromkeys(reasons)),
                missing=list(dict.fromkeys(missing)), point_client=None, point_desktop=None,
                runtime_input_authorized=False, live_verified=False, **extra)


def _dialogue_rect(binding, parent, scale, orientation):
    """MMML947/948/949 from complete source-bound keyword lists, after animation."""
    if orientation not in ('landscape','portrait'):raise ValueError('unknown dialogue orientation')
    portrait=orientation=='portrait' 
    if binding.get('complete_lists') is not True or binding.get('animation_complete') is not True:
        raise ValueError('complete current keyword lists and completed animation required')
    if binding.get('content_metrics_source')!='native-layout':
        raise ValueError('general button widths require native content metrics, not OCR boxes')
    general=binding['general_buttons'];groups=binding['special_groups']
    all_buttons=[*general,*(b for g in groups for b in g['buttons'])]
    keys=[str(b['keyword_id']) for b in all_buttons]
    if len(keys)!=len(set(keys)):raise ValueError('duplicate keyword identities')
    chosen=[b for b in all_buttons if str(b['keyword_id'])==str(binding['keyword_id'])]
    if len(chosen)!=1 or chosen[0]['label']!=binding['label'] or chosen[0].get('is_enable') is not True:
        raise ValueError('current selected keyword is absent, mismatched, or disabled')
    gap=12*scale;out={};lines=[];line=[];used=0
    for button in general:
        width=float(button['content_width_ui'])*scale
        if not 0<width<=parent.width:raise ValueError('general content width out of parent bounds')
        if line and used+gap+width>parent.width:
            lines.append(line);line=[];used=0
        line.append((button,width));used+=width+(gap if len(line)>1 else 0)
    if line:lines.append(line)
    general_height=len(lines)*64*scale+max(0,len(lines)-1)*gap
    # 947 PC978..1066: portrait general flow retains a 350-UI minimum.
    # Flow's omitted cross alignment defaults to the positive break start.
    general_extent=max(350*scale,general_height) if portrait and general else general_height
    top=parent.bottom-general_extent
    for line in lines:
        total=sum(w for _,w in line)+gap*(len(line)-1)
        left=parent.left+(parent.width-total)/2
        for button,width in line:
            out[str(button['keyword_id'])]=Rect(left,top,width,64*scale);left+=width+gap
        top+=64*scale+gap
    ordered=[g for kind in ('purple','blue','red') for g in groups if g['button_type']==kind]
    if len(ordered)!=len(groups):raise ValueError('unknown special dialogue group type')
    bottom=parent.bottom-general_extent-(32*scale if general and groups else 0)
    if portrait:
        # 947 PC465..725: groups flow UP in purple/blue/red order. Each
        # 612.5-UI group flows right with breakDir UP, two 300x80 buttons.
        # Only a single group centers its rows; multiple groups align left.
        group_left=parent.center[0]-306.25*scale
        for group in ordered:
            buttons=group['buttons']
            if not buttons:raise ValueError('empty special group state ambiguous')
            for start in range(0,len(buttons),2):
                row=buttons[start:start+2]
                width=len(row)*300*scale+(len(row)-1)*gap
                left=parent.center[0]-width/2 if len(groups)==1 else group_left
                top=bottom-(start//2+1)*80*scale-(start//2)*gap
                for button in row:
                    out[str(button['keyword_id'])]=Rect(left,top,300*scale,80*scale)
                    left+=300*scale+gap
            rows=(len(buttons)+1)//2
            bottom-=rows*80*scale+rows*gap
    else:
        total=len(ordered)*300*scale+max(0,len(ordered)-1)*gap
        left=parent.left+(parent.width-total)/2
        for group in ordered:
            if not group['buttons']:raise ValueError('empty special group state ambiguous')
            top=bottom-len(group['buttons'])*80*scale-(len(group['buttons'])-1)*gap
            for button in group['buttons']:
                out[str(button['keyword_id'])]=Rect(left,top,300*scale,80*scale);top+=80*scale+gap
            left+=300*scale+gap
    result=out[str(binding['keyword_id'])]
    if not parent.contains(result):raise ValueError('keyword clipped by current flow parent')
    return result


def build_target(target_id, viewport, current_ui):
    """Return a source-derived candidate or an explicit, machine-readable gap.

    viewport: {width,height,origin:[desktop_x,desktop_y]} in physical pixels.
    current_ui: current frame metadata, profile and source-bound bindings. See
    data/arbeit-ui-map.json/current_ui_contract. No timestamps are manufactured.
    Callers must recheck frame/window ownership immediately before dispatch.
    """
    rules=ui_map()
    target=rules['targets'].get(target_id)
    if target is None:return _failure(target_id,['unknown_target'])
    refs=target['source_refs']
    if target['mode']=='unmapped':
        return _failure(target_id,['source_target_unmapped'],target['requires'],
                        gap=target['gap'],source_refs=refs)
    current_ui=current_ui or {}
    missing=[]
    for key in ('frame_id','observed_at','session_id','window_signature'):
        if not current_ui.get(key):missing.append(key)
    if current_ui.get('evidence_kind')!='current-frame':missing.append('current-frame evidence')
    binding=current_ui.get('bindings',{}).get(target_id,{})
    if not binding:missing.append('bindings.'+target_id)
    if missing:return _failure(target_id,['current_ui_missing'],missing,source_refs=refs)
    try:
        stamp=datetime.fromisoformat(current_ui['observed_at'].replace('Z','+00:00'))
        if stamp.tzinfo is None:raise ValueError('observed_at requires timezone')
        view=Viewport(viewport['width'],viewport['height'],tuple(viewport.get('origin',(0,0))))
        current_view=current_ui.get('viewport',{})
        if [current_view.get('width'),current_view.get('height'),current_view.get('origin',[0,0])]!=[view.width,view.height,list(view.origin)]:
            return _failure(target_id,['viewport_changed'],['current viewport binding'],source_refs=refs)
        if binding.get('frame_id')!=current_ui['frame_id']:
            return _failure(target_id,['stale_binding'],['binding for current frame'],source_refs=refs)
        pointer_kind=binding.get('input_kind')
        probing=pointer_kind=='pointer_probe' and binding.get('pointer_probe_ready') is True
        if binding.get('enabled') is not True or (binding.get('occluded') is not False and not
                (probing and binding.get('occluded') is None)):
            return _failure(target_id,['target_not_confirmed_interactable'],['enabled and unobscured target'],source_refs=refs)
        if binding.get('screen')!=target.get('screen','hud'):
            return _failure(target_id,['screen_mismatch'],['current screen identity'],source_refs=refs)
        label=target.get('label')
        if label and binding.get('label') not in (label if isinstance(label,list) else [label]):
            return _failure(target_id,['label_mismatch'],['exact target label'],source_refs=refs)
        if target_id in ('arbeit.apply','quest.track','reward.confirm','dialogue.accept','dialogue.report','dialogue.keyword','delivery.item','delivery.submit'):
            expected=current_ui.get('goal',{})
            if not expected.get('quest_id') or str(binding.get('quest_id'))!=str(expected['quest_id']):
                return _failure(target_id,['quest_identity_missing_or_mismatch'],['exact current quest ID'],source_refs=refs)
            if target_id in ('arbeit.apply','dialogue.accept','dialogue.report','dialogue.keyword','delivery.item','delivery.submit') and (not expected.get('npc') or binding.get('npc')!=expected['npc']):
                return _failure(target_id,['npc_identity_missing_or_mismatch'],['current quest NPC'],source_refs=refs)
            request=binding.get('request_binding') or {}
            probe_request=(binding.get('probe_context') or {}).get('request_binding') or {}
            same_request=False
            if request and probe_request:
                from .direct_dialogue_input_guard import request_identity
                try:same_request=request_identity(request)==request_identity(probe_request)
                except ValueError:pass
            request_input=(target_id in ('dialogue.accept','dialogue.report')
                and request.get('target_id')==target_id
                and request.get('current_dialogue_input_owned') is True
                and request.get('quest_effect_owner_verified') is False
                and request.get('quest_id')==expected.get('quest_id')
                and str((request.get('transaction') or {}).get('npc_id'))==expected.get('npc')
                and request.get('transaction_id') and request.get('before_scope')
                and (binding.get('probe_context') or {}).get('source_guard_kind')=='direct-dialogue-request'
                and same_request)
            if target_id.startswith(('dialogue.','delivery.')) and binding.get('ownership_verified') is not True and not request_input:
                return _failure(target_id,['current_quest_npc_ownership_missing'],['current offered/accepted quest and NPC relation'],source_refs=refs)
        common=dict(target_id=target_id,status='ready',reason_codes=[],missing=[],
                    frame_id=current_ui['frame_id'],observed_at=current_ui['observed_at'],
                    session_id=current_ui['session_id'],window_signature=current_ui['window_signature'],
                    source_refs=refs,runtime_input_authorized=False,live_verified=False,
                    meaning='geometric candidate only; not permission or effect proof')
        if target_id=='delivery.submit':
            required_items=binding.get('required_by_item',{});selected_items=binding.get('selected_by_item',{})
            if (not required_items or selected_items!=required_items or any(type(v) is not int or v<=0 for v in required_items.values())
                    or binding.get('is_deliver_button_enabled') is not True):
                return _failure(target_id,['delivery_reservation_not_complete'],['actual required and selected item quantities'],source_refs=refs)
        if target_id=='delivery.item' and binding.get('item_identity_verified') is not True:
            return _failure(target_id,['current_delivery_item_layout_missing'],['exact current item identity'],source_refs=refs)
        def pointer_result(rect):
            point=binding.get('point_client',list(rect.center)) if pointer_kind in ('pointer_probe','pointer_click') else list(rect.center)
            if (not isinstance(point,(list,tuple)) or len(point)!=2
                    or any(type(v) not in (int,float) or not math.isfinite(v) for v in point)):
                raise ValueError('current pointer point must be finite physical pixels')
            x,y=round(point[0]),round(point[1])
            if not rect.left<=x<rect.right or not rect.top<=y<rect.bottom:
                raise ValueError('current pointer point outside source rectangle')
            data=dict(input_kind=pointer_kind if pointer_kind in ('pointer_probe','pointer_click') else 'click',
                point_client=[x,y],point_desktop=list(view.to_screen((x,y))))
            if pointer_kind in ('pointer_probe','pointer_click'):
                if not isinstance(binding.get('probe_context'),dict) or not binding['probe_context']:
                    raise ValueError('current pointer context missing')
                data.update(probe_context=binding['probe_context'],raycast_proof=binding.get('raycast_proof'),
                    pointer_verified=binding.get('pointer_verified',binding.get('raycast_proof')),
                    source_rect=list(rect.bounds),safe_rect=current_ui['safe_rect'])
            return data
        if target['mode']=='key':
            if binding.get('key')!=target['key'] or binding.get('keymap_observed') is not True:
                return _failure(target_id,['current_keymap_missing'],['observed Map=M mapping'],source_refs=refs)
            return dict(common,input_kind='key',key=target['key'],point_client=None,point_desktop=None)
        if binding.get('rect_source')=='current-native-output':
            proof=binding.get('native_geometry_proof') or {}
            required=('source_bound','layout_stable','semantic_unique','current_identity_bound')
            if any(binding.get(k) is not True for k in required):
                return _failure(target_id,['current_native_binding_missing'],list(required),source_refs=refs)
            if (any(proof.get(k) is not True for k in ('confirmed','transform_verified','active_hierarchy_verified','source_revision_verified'))
                    or proof.get('coordinate_space')!='physical-client-pixels' or not proof.get('source')
                    or proof.get('frame_id')!=current_ui['frame_id']
                    or proof.get('window_signature')!=current_ui['window_signature']
                    or proof.get('observed_at')!=current_ui['observed_at']):
                return _failure(target_id,['current_native_transform_proof_missing'],source_refs=refs)
            for pointer in ('ui_pointer','control_pointer','root_object_pointer'):
                if type(proof.get(pointer)) is not int or proof[pointer]<=0 or proof[pointer]!=binding.get(pointer):
                    return _failure(target_id,['current_native_control_owner_mismatch'],[pointer],source_refs=refs)
            if proof['ui_pointer']!=(current_ui.get('layout_snapshot') or {}).get('ui_pointer'):
                return _failure(target_id,['current_native_root_mismatch'],source_refs=refs)
            hashes=proof.get('source_hashes')
            if not isinstance(hashes,list) or not hashes or not set(hashes).issubset(set(current_ui.get('source_hashes',[]))):
                return _failure(target_id,['current_native_source_revision_missing'],source_refs=refs)
            values=binding.get('rect_client')
            if not isinstance(values,list) or len(values)!=4 or any(type(v) not in (int,float) or not math.isfinite(v) for v in values):
                return _failure(target_id,['current_native_rectangle_missing'],source_refs=refs)
            rect=Rect(*values);safe=Rect(*current_ui['safe_rect'])
            if rect.width<=0 or rect.height<=0 or not view.rect.contains(safe) or not safe.contains(rect):
                return _failure(target_id,['target_clipped'],source_refs=refs)
            x,y=round(rect.center[0]),round(rect.center[1])
            if not rect.left<=x<rect.right or not rect.top<=y<rect.bottom:
                return _failure(target_id,['empty_pixel_target'],source_refs=refs)
            return dict(common,**pointer_result(rect),
                rect=values,native_geometry_proof=proof,
                formula='current native RectTransform corners converted to physical client pixels')
        if target['mode']=='mmml_output' or (target.get('runtime_layout_tags') and binding.get('rect_source')=='current-mmml-output'):
            required=('source_bound','layout_stable','semantic_unique','current_identity_bound')
            if any(binding.get(key) is not True for key in required):
                return _failure(target_id,['current_mmml_binding_missing'],[key for key in required if binding.get(key) is not True],source_refs=refs)
            evidence=target['evidence'][0]
            if evidence['payload_sha256'] not in current_ui.get('source_hashes',[]):
                return _failure(target_id,['source_revision_unverified'],[evidence['payload_sha256']],source_refs=refs)
            tags=target.get('runtime_layout_tags',evidence.get('tags',[]))
            if binding.get('tag') not in tags:
                return _failure(target_id,['source_node_mismatch'],tags,source_refs=refs)
            if target_id=='delivery.submit':
                required_items=binding.get('required_by_item',{});selected_items=binding.get('selected_by_item',{})
                if not required_items or selected_items!=required_items or any(type(v) is not int or v<=0 for v in required_items.values()):
                    return _failure(target_id,['delivery_reservation_not_complete'],['actual required and selected item quantities'],source_refs=refs)
                if binding.get('is_deliver_button_enabled') is not True:
                    return _failure(target_id,['current_delivery_grid_binding_missing'],['current uiData.isDeliverButtonEnabled'],source_refs=refs)
            if target_id=='delivery.item' and binding.get('item_identity_verified') is not True:
                return _failure(target_id,['current_delivery_item_layout_missing'],['exact current item identity'],source_refs=refs)
            scale=current_ui.get('layout_scale')
            if type(scale) not in (int,float) or not 0<scale<20:
                return _failure(target_id,['current_layout_scale_missing'],['MMMLService.lastReferenceResolutionScale'],source_refs=refs)
            ui_rect=Rect(*binding['rect_ui'])
            rect=Rect(ui_rect.left*scale,ui_rect.top*scale,ui_rect.width*scale,ui_rect.height*scale)
            # This path uses the client's actual current layout scale, not an
            # invented PC style or rescaling a 16:9 historical coordinate.
            safe=Rect(*current_ui['safe_rect'])
            if not view.rect.contains(safe) or not safe.contains(rect):
                return _failure(target_id,['target_clipped'],['fully visible current control'],source_refs=refs)
            x,y=round(rect.center[0]),round(rect.center[1])
            if not rect.left<=x<rect.right or not rect.top<=y<rect.bottom:
                return _failure(target_id,['empty_pixel_target'],source_refs=refs)
            return dict(common,**pointer_result(rect),
                        rect=[rect.left,rect.top,rect.width,rect.height],scale=scale,
                        formula='actual stable LayoutVM rectangle * actual MMMLService.lastReferenceResolutionScale',
                        layout_source=binding.get('layout_source'),layout_revision=binding.get('layout_revision'))
        if pointer_kind in ('pointer_probe','pointer_click'):
            return _failure(target_id,['current_pointer_requires_native_or_mmml_rectangle'],source_refs=refs)
        profile=current_ui.get('profile',{})
        if profile.get('confirmed') is not True or not profile.get('source'):
            return _failure(target_id,['current_profile_missing'],['confirmed canvas profile'],source_refs=refs)
        safe=Rect(*current_ui['safe_rect'])
        view=Viewport(view.width,view.height,view.origin,safe)
        context=resolve_canvas(view,profile['style'],profile['percent'],orientation=profile['orientation'],
            profile=CanvasProfile(profile['reference_width'],profile['reference_height'],profile['minimum_width'],profile['source'],True))
        if binding.get('source_bound') is not True or not binding.get('layout_source') or not binding.get('layout_revision'):
            return _failure(target_id,['current_parent_binding_missing'],target['requires'],source_refs=refs)
        # A changed client cannot reuse an old static rule by merely setting a
        # version string. Its exact UI payload/rule hash must have been checked.
        expected_hashes={r['payload_sha256'] for r in target.get('evidence',[])}
        if not expected_hashes:expected_hashes={r['sha256'] for r in refs}
        if not expected_hashes.issubset(set(current_ui.get('source_hashes',[]))):
            return _failure(target_id,['source_revision_unverified'],sorted(expected_hashes),source_refs=refs)
        if target['mode']=='tracker_first':
            if binding.get('index')!=0 or binding.get('first_row_identity_confirmed') is not True:
                return _failure(target_id,['tracker_selection_unbound'],['first tracker row identity'],source_refs=refs)
            orientation=context.orientation
            top={'landscape':244,'portrait':332}[orientation]
            s=context.scale
            rect=Rect(safe.right-100*s,safe.top+(top+14)*s,70*s,10*s)
            formula='safe.right - 100*s; safe.top + (tracker_top + 14)*s; 70*s by 10*s'
        else:
            node=binding.get('node_path','')
            allowed_nodes={r['node_path'] for r in target['evidence'] if r['node_path'].endswith(target['node_suffix'])}
            if node not in allowed_nodes:
                return _failure(target_id,['source_node_mismatch'],[target['node_suffix']],source_refs=refs)
            parent=Rect(*binding['parent_rect'])
            if target['mode']=='resolved_layout':
                if binding.get('layout_completed') is not True or not binding.get('active_children'):
                    return _failure(target_id,['dynamic_layout_unresolved'],target['requires'],source_refs=refs)
                # This is a current resolved native layout rectangle, NEVER an
                # OCR glyph rectangle, world coordinate, or editor default.
                if binding.get('rect_source')!='native-layout-rule':
                    return _failure(target_id,['layout_rect_source_rejected'],['native-layout-rule rectangle'],source_refs=refs)
                rect=Rect(*binding['resolved_rect'])
                if not parent.contains(rect):
                    return _failure(target_id,['target_outside_parent'],['unclipped source parent'],source_refs=refs)
                formula='current native layout rule output; center of fully visible source-bound raycast rectangle'
            elif target['mode']=='reward_single':
                if binding.get('active_children')!=['Button_Action']:
                    return _failure(target_id,['reward_layout_variant_unresolved'],['only Button_Action active'],source_refs=refs)
                if binding.get('reward_title_confirmed') is not True:
                    return _failure(target_id,['reward_identity_unconfirmed'],['exact quest reward title'],source_refs=refs)
                s=context.scale
                rect=Rect(parent.left+48*s,parent.bottom-(32+80)*s,parent.width-96*s,80*s)
                formula='HorizontalLayoutGroup sole active child: left/right 48*s, bottom 32*s, height 80*s'
            elif target['mode']=='dialogue_flow':
                rect=_dialogue_rect(binding,parent,context.scale,context.orientation)
                formula='MMML947/948/949 complete source-bound lists; landscape special columns or portrait 612.5-wide groups wrapping upward; portrait general min350; 12-unit gaps and 32-unit section gap'
            elif target['mode']=='delivery_submit':
                required=binding.get('required_by_item',{});selected=binding.get('selected_by_item',{})
                if not required or selected!=required or any(type(v) is not int or v<=0 for v in required.values()):
                    return _failure(target_id,['delivery_reservation_not_complete'],['actual required and selected item quantities'],source_refs=refs)
                if binding.get('is_deliver_button_enabled') is not True or binding.get('alignment_source')!='native-layout':
                    return _failure(target_id,['current_delivery_grid_binding_missing'],target['requires'],source_refs=refs)
                # parent is the current source-bound topArea, not the OCR form.
                s=context.scale
                if parent.height<234*s:raise ValueError('delivery topArea cannot fit source fixed rows')
                cell=Rect(parent.left,parent.top+parent.height-134*s,parent.width,64*s)
                ax,ay=binding['button_alignment']
                if ax not in (0,.5,1) or ay not in (0,.5,1):raise ValueError('unresolved delivery button alignment')
                rect=Rect(cell.left+(cell.width-480*s)*ax,cell.top+(cell.height-64*s)*ay,480*s,64*s)
                if not cell.contains(rect):raise ValueError('delivery button clipped in source grid cell')
                formula='MMML324 topArea inner row4 of 60px 1fr 40px 64px 38px 32px; button480x64; explicit current native alignment'
            elif target['mode']=='delivery_grid':
                items=binding['ordered_item_ids'];item=str(binding['item_id'])
                if len(items)!=len(set(map(str,items))) or item not in list(map(str,items)):
                    raise ValueError('current delivery item identity/order ambiguous')
                if binding.get('item_identity_verified') is not True or binding.get('flow_defaults_source')!='native-layout':
                    raise ValueError('current item identity and native flow defaults required')
                if binding.get('flow_defaults')!={'direction':'right','align':'left','hgap_ui':0,'break_direction':'down'}:
                    raise ValueError('delivery flow variant not compiled')
                s=context.scale;columns=int(parent.width//(128*s))
                if columns<1:raise ValueError('delivery content too narrow')
                index=list(map(str,items)).index(item);scroll=float(binding['scroll_y'])
                if scroll<0:raise ValueError('negative scroll offset')
                rect=Rect(parent.left+(index%columns)*128*s,parent.top+56*s+(index//columns)*(138+16)*s-scroll,128*s,138*s)
                clip=Rect(*binding['clip_rect'])
                if not clip.contains(rect):return _failure(target_id,['delivery_item_clipped'],['fully visible item cell'],source_refs=refs)
                formula='MMML324/326/238 wrapping 128x138 item cells; top padding56, row gap16, observed flow defaults/order/scroll'
            else:return _failure(target_id,['unsupported_formula'],source_refs=refs)
        if not safe.contains(rect):
            return _failure(target_id,['target_clipped'],['fully visible control'],source_refs=refs)
        result=target_from_rect(context,rect,click_inset=min(3,rect.width/4,rect.height/4))
        return dict(common,input_kind='click',point_client=list(result.client_point),
                    point_desktop=list(result.screen_point),rect=[rect.left,rect.top,rect.width,rect.height],
                    formula=formula,scale=context.scale,layout_source=binding['layout_source'],
                    layout_revision=binding['layout_revision'])
    except (ValueError,TypeError,KeyError,OverflowError) as exc:
        return _failure(target_id,['invalid_or_incomplete_ui_geometry'],[str(exc)],source_refs=refs)


def _normalized(text):
    return re.sub(r'\s+','',str(text))


def read_texts(texts, *, width, height, expected, frame, ui_percent=70):
    """Legacy scoped caption logic, restricted to its authored 16:9/UI70 view.

    Returns only observed semantic facts; no click bindings and no completion.
    Text rows: {text,score,box:[left,top,right,bottom]}. Input may come from the
    actual OfflineOCR reader or explicitly labelled offline fixtures in tests.
    """
    output={'schema':1,'reader':'arbeit-ocr-semantic-20261009-r1','frame':dict(frame),
            'facts':{},'uncertainty':[],'bindings':{},'game_completion_confirmed':False,
            'coordinate_bindings_supplied':False,'source':'legacy scoped captions; OCR boxes are not actuators'}
    if type(width) is not int or type(height) is not int or width<=0 or height<=0 or width*9!=height*16 or ui_percent!=70:
        output['uncertainty'].append('semantic_reader_profile_unsupported');return output
    valid=[]
    for row in texts:
        try:
            box=row['box']
            if len(box)!=4 or not all(type(v) in (int,float) for v in box):continue
            if not (0<=box[0]<box[2]<=width and 0<=box[1]<box[3]<=height):continue
            if not 0<=float(row['score'])<=1:continue
            valid.append(row)
        except (KeyError,TypeError,ValueError):continue
    def matches(text,roi,contains=False):
        if not text:return []
        found=[]
        for row in valid:
            box=row['box'];x=(box[0]+box[2])/2/width;y=(box[1]+box[3])/2/height
            if float(row['score'])<.82 or not (roi[0]<=x<=roi[2] and roi[1]<=y<=roi[3]):continue
            actual,wanted=_normalized(row['text']),_normalized(text)
            if (wanted in actual if contains else wanted==actual):found.append(row)
        return found
    def fact(name,text,roi,contains=False):
        found=matches(text,roi,contains)
        if len(found)==1:
            output['facts'][name]={'observed_text':found[0]['text'],'box':found[0]['box'],'score':found[0]['score']}
        elif len(found)>1:output['uncertainty'].append(name+':ambiguous')
    fact('npc_name',expected.get('npc'),(.15,.03,.85,.5))
    fact('arbeit_offer','아르바이트 공고',(.1,.1,.9,.7),True)
    fact('accept_caption','수락',(.1,.7,.9,1))
    fact('report_caption','결과 보고',(.1,.75,.9,1))
    fact('skip_caption','대화 넘기기',(.75,0,1,.15))
    fact('current_quest_title',expected.get('title'),(.5,.08,1,.7))
    fact('reward_heading','퀘스트 클리어',(.25,.05,.75,.27),True)
    fact('reward_quest_title',expected.get('title'),(.25,.15,.75,.32))
    fact('reward_confirm_caption','확인',(.3,.85,.7,1))
    fact('delivery_heading','전달하기',(0,0,.25,.1))
    fact('delivery_auto_insert','자동 넣기',(.15,.52,.4,.68))
    fact('delivery_submit_caption','전달하기',(.2,.46,.5,.6))
    fact('delivery_item',expected.get('item'),(.26,.3,.43,.4))
    facts=output['facts']
    output['reward_title_observed']=all(k in facts for k in ('reward_heading','reward_quest_title','reward_confirm_caption'))
    output['offer_npc_observed']=all(k in facts for k in ('npc_name','arbeit_offer','accept_caption'))
    output['offer_quest_id_observed']=False  # Captions do not expose server quest identity.
    if all(k in facts for k in ('delivery_heading','delivery_auto_insert','delivery_item')):
        counters=[]
        for row in valid:
            box=row['box'];x=(box[0]+box[2])/2/width;y=(box[1]+box[3])/2/height
            match=re.fullmatch(r'(\d+)/(\d+)',_normalized(row['text']))
            if match and row['score']>=.82 and .28<=x<=.4 and .25<=y<=.34:
                selected,required=map(int,match.groups())
                if 0<=selected<=required and required>0:counters.append((selected,required,row))
        if len(counters)==1:
            selected,required,row=counters[0]
            facts['delivery_reservation']={'selected':selected,'required':required,'observed_text':row['text'],'box':row['box'],'score':row['score']}
            if expected.get('quantity') is not None and expected['quantity']!=required:
                output['uncertainty'].append('delivery_required_quantity_mismatch')
        else:output['uncertainty'].append('delivery_reservation_missing_or_ambiguous')
    return output


class OfflineOCR:
    """Real pixel OCR using copied local models; no download or game access."""
    def __init__(self, threads=2):
        model_root=root()/'data/arbeit-ocr'
        self.manifest=json.loads((model_root/'manifest.json').read_text(encoding='utf-8'))
        for name,info in self.manifest['models'].items():
            if hashlib.sha256((model_root/name).read_bytes()).hexdigest()!=info['sha256']:
                raise ValueError('OCR model integrity failure: '+name)
        import rapidocr
        from rapidocr import OCRVersion,ModelType,LangRec
        self.engine=rapidocr.RapidOCR(params={
            'Det.model_path':str(model_root/'det.onnx'),'Rec.model_path':str(model_root/'rec.onnx'),
            'Cls.model_path':str(model_root/'cls.onnx'),'Det.ocr_version':OCRVersion.PPOCRV5,
            'Rec.ocr_version':OCRVersion.PPOCRV5,'Det.model_type':ModelType.MOBILE,
            'Rec.model_type':ModelType.MOBILE,'Rec.lang_type':LangRec.KOREAN,
            'Global.use_cls':False,'Global.log_level':'error','Global.text_score':.50,
            'Global.max_side_len':4096,'Det.limit_side_len':1280,'Det.limit_type':'max',
            'EngineConfig.onnxruntime.intra_op_num_threads':threads,
            'EngineConfig.onnxruntime.inter_op_num_threads':1})

    def read(self,image):
        import numpy as np
        rgb=image.convert('RGB')
        result=self.engine(np.asarray(rgb)[:,:,::-1].copy())
        texts=[]
        if result.boxes is not None and result.txts is not None:
            for box,text,score in zip(result.boxes,result.txts,result.scores):
                texts.append({'text':text,'score':float(score),'box':[float(box[:,0].min()),float(box[:,1].min()),float(box[:,0].max()),float(box[:,1].max())]})
        return texts


def _read_cards(image,texts,expected):
    """Read current card contours; their boxes are NOT source click targets.

    Adapted from jobs_screen.job_cards with the same authored color/size bounds.
    Only the exact requested visible title is returned; no cached/server ID is
    inserted into this observation and absent cards do not mean unavailable.
    """
    import cv2
    import numpy as np
    width,height=image.size
    def inside(row,region):
        box=row['box'];x=(box[0]+box[2])/2;y=(box[1]+box[3])/2
        return region[0]<=x<=region[2] and region[1]<=y<=region[3] and row['score']>=.82
    headings=[r for r in texts if '구인중' in _normalized(r['text']) and inside(r,(width*.68,height*.08,width*.8,height*.17))]
    if len(headings)!=1:return []
    scale=width/2048
    pixels=np.asarray(image.convert('RGB').resize((2048,round(height/scale))))
    r,g,b=(pixels[:,:,i].astype(int) for i in range(3))
    mask=((r>=27)&(r<=80)&(g<=90)&(b<=100)).astype('uint8')*255
    mask[:round(pixels.shape[0]*.14)]=0;mask[round(pixels.shape[0]*.91):]=0
    mask[:,:round(2048*.67)]=0;mask[:,round(2048*.99):]=0
    mask=cv2.morphologyEx(mask,cv2.MORPH_CLOSE,np.ones((7,7),np.uint8))
    contours,_=cv2.findContours(mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
    cards=[]
    for contour in contours:
        x,y,w,h=cv2.boundingRect(contour)
        if not (w>2048*.25 and pixels.shape[0]*.12<h<pixels.shape[0]*.65):continue
        box=[x*scale,y*scale,(x+w)*scale,(y+h)*scale]
        rows=[r for r in texts if inside(r,box)]
        titles=[r for r in rows if _normalized(r['text'])==_normalized(expected.get('title',''))]
        buttons=[r for r in rows if _normalized(r['text']) in ('지원하기','진행하기')]
        if len(titles)!=1 or len(buttons)!=1:continue
        cards.append({'title':titles[0]['text'],'button_caption':buttons[0]['text'],
                      'observed_text':[r['text'] for r in rows], 'visual_contour_box':box,
                      'title_glyph_box':titles[0]['box'],'button_glyph_box':buttons[0]['box'],
                      'quest_id':None,'source_layout_binding':False,'may_use_as_click_target':False})
    return sorted(cards,key=lambda c:c['visual_contour_box'][1])


def read_pixels(image, *, expected, frame, ocr=None, ui_percent=70):
    """Consume captured PIL pixels; preserve the caller's read-only provenance."""
    if not frame.get('captured_at') or not frame.get('window_signature'):
        raise ValueError('capture time and window binding required')
    start=time.monotonic()
    image=image.convert('RGB')
    pixel_hash=hashlib.sha256(image.tobytes()).hexdigest()
    backend=ocr or OfflineOCR()
    texts=backend.read(image)
    frame['ocr_rows']=texts
    frame['ocr_rows_frame_id']=frame.get('frame_id',frame.get('sha256'))
    output=read_texts(texts,width=image.width,height=image.height,
        expected=expected,frame={**frame,'pixels_sha256':pixel_hash,'width':image.width,'height':image.height},ui_percent=ui_percent)
    output['cards']=_read_cards(image,texts,expected) if image.width*9==image.height*16 and ui_percent==70 else []
    output['elapsed_ms']=round((time.monotonic()-start)*1000,2)
    output['input_source']='actual-pixel-ocr' if isinstance(backend,OfflineOCR) else 'injected-pixel-reader'
    output['backend']=type(backend).__name__
    return output
