"""Pure, explicit-policy bag recovery; no screen capture or game input.

The mini26 material constraints, source geometry and input order are retained.
Dispatch completion is never proof of removal or of the parent goal recovering.
Actual observation is provided at the current-frame boundary below.
"""
from copy import deepcopy
from datetime import datetime, timezone
import hashlib
import json
import math
import unicodedata

from .paths import root
from .vendor.ui_layout import CanvasContext, CanvasProfile, LayoutError, Rect, Viewport, resolve_canvas


def _resource_dir():
    return root() / 'data/arbeit-bag-source/mini26/resources'


def _hash(value):
    return hashlib.sha256(json.dumps(value,sort_keys=True,ensure_ascii=False).encode()).hexdigest()


def _stamp(value):
    if not isinstance(value,str):raise ValueError('timestamp_required')
    dt=datetime.fromisoformat(value.replace('Z','+00:00'))
    if dt.tzinfo is None:raise ValueError('timezone_required')
    return dt


def _current(proof,snapshot):
    try:
        return (isinstance(proof,dict) and bool(proof.get('source')) and
            all(proof.get(k)==snapshot.get(k) and bool(proof.get(k)) for k in ('character_id','session_id')) and
            -.5<=(_stamp(snapshot['observed_at'])-_stamp(proof['observed_at'])).total_seconds()<=5)
    except (KeyError,TypeError,ValueError):return False


def _wait(reason,receipt=None,missing=()):
    return dict(status='waiting',reason=reason,missing=list(missing),proposal=None,
        next_receipt=deepcopy(receipt),prior_effect_confirmed=False,effect_evidence=[],sent_to_game=False)


def _policy(value):
    if not isinstance(value,dict):raise ValueError('explicit_user_cleanup_policy_required')
    name,keep=value.get('name'),value.get('keep')
    if not value.get('policy_id') or value.get('source')!='explicit_user_policy':
        raise ValueError('explicit_user_cleanup_policy_required')
    if (type(name) is not str or not name.strip() or any(ord(c)<32 or ord(c)==127 or 0xD800<=ord(c)<=0xDFFF for c in name)
            or not 1<=len(name.encode('utf-16-le'))//2<=50 or value.get('category')!='Ingredient'
            or type(keep) is not int or not 0<=keep<=2147483647):
        raise ValueError('invalid_material_cleanup_policy')
    return {k:value[k] for k in ('policy_id','name','category','keep','source')}


def _rule(name):
    data=json.loads((_resource_dir()/'bag-cleanup-materials.json').read_text(encoding='utf-8'))
    row=data['materials'].get(name)
    if not isinstance(row,dict) or row.get('action') not in ('sell','discard','unavailable') or type(row.get('warning')) is not bool:
        raise ValueError('exact_material_rule_missing')
    if row['action']!='unavailable':
        roles=row.get('detail_roles',[])
        if (not roles or roles[0]!='Lock' or len(roles)!=len(set(roles)) or row['action'].title() not in roles or
                {'Sell','Discard'}<=set(roles)):
            raise ValueError('material_detail_roles_invalid')
    return row


def _objective_reservations(policy,snapshot):
    """Count current holds and occurrence-validated commitments exactly once.

    A durable commitment keeps its original source/time. current_owned_at proves
    that the caller still owns that quest occurrence, not that an old HUD row was
    observed again. Only the caller's typed delivery/completion proof may release
    it; a missing HUD row is not such a proof.
    """
    from .goal_reservations import reservation_current,reservation_quantity
    gaps=snapshot.get('goal_reservation_gaps',[])
    if not isinstance(gaps,list):raise ValueError('parent_item_reservation_gaps_unavailable')
    if any(isinstance(g,dict) and g.get('item_name')==policy['name'] for g in gaps):
        raise ValueError('parent_item_required_quantity_unresolved')
    durable=snapshot.get('goal_reservations',[])
    if not isinstance(durable,list):raise ValueError('durable_parent_item_reservations_unavailable')
    reservations={};evidence=[];typed_scopes=set()
    def base(goal):
        if any(goal.get(k) is None for k in ('quest_id','step_id','objective_index')):
            raise ValueError('parent_item_reservation_scope_missing')
        return tuple(str(goal[k]) for k in ('quest_id','step_id','objective_index'))+(policy['name'],)
    def quantity(goal):
        return reservation_quantity(goal)
    for goal in durable:
        if not isinstance(goal,dict) or goal.get('item_name')!=policy['name'] or goal.get('active') is False:
            continue
        typed=goal.get('reservation_kind')=='typed_inventory_hold'
        if typed and goal.get('item_domain')=='EquipmentSheet':
            # This policy disposes only Ingredient/Consumable bag contents.
            # An equal display name or numeric ID in Equipment is not a join.
            if not reservation_current(goal,snapshot,_current):raise ValueError('durable_parent_item_reservation_unvalidated')
            continue
        current_owner=dict(goal,observed_at=goal.get('current_owned_at'))
        accept_time=goal.get('accept_time')
        if (goal.get('active') is not True or goal.get('validated_by_current_occurrence') is not True or
                not _current(current_owner,snapshot) or
                (not reservation_current(goal,snapshot,_current) if typed else goal.get('source') not in ('get_quests','official:get_quests','official-cli:get_quests')) or
                goal.get('item_category') not in (None,'Ingredient') or type(accept_time) is not int or accept_time<=0):
            raise ValueError('durable_parent_item_reservation_unvalidated')
        try:
            if (_stamp(goal['observed_at'])-_stamp(snapshot['observed_at'])).total_seconds()>.5:
                raise ValueError('future_original_reservation_evidence')
        except (KeyError,TypeError,AttributeError,ValueError) as exc:
            raise ValueError('original_parent_item_reservation_time_invalid') from exc
        key=base(goal)+(accept_time,)
        if typed:typed_scopes.add(base(goal))
        reservations[key]=max(reservations.get(key,0),quantity(goal));evidence.append(deepcopy(goal))
    for request in snapshot.get('inventory_hold_requests',[]):
        if request.get('item_name')!=policy['name'] or (request.get('item') or {}).get('table_domain')=='EquipmentSheet':continue
        # Actual executor reconciliation precedes bag planning. Never ignore a
        # current typed hold just because its durable channel was not wired.
        if not any(r.get('reservation_kind')=='typed_inventory_hold' and r.get('active') is True
                   and r.get('node_id')==request.get('node_id') and
                   all(r.get(k)==request.get(k) for k in ('instance_id','accept_time','process_started_at'))
                   for r in durable):raise ValueError('typed_parent_item_reservation_not_reconciled')
    goals=snapshot.get('objective_evidence',[])
    if isinstance(goals,dict):goals=list(goals.values())
    if not isinstance(goals,list):raise ValueError('current_objective_reservations_unavailable')
    seen=set()
    for goal in goals:
        if not isinstance(goal,dict) or goal.get('semantics')!='inventory.hold' or goal.get('item_name')!=policy['name']:
            continue
        if (not _current(goal,snapshot) or not goal.get('identity_binding') or
                goal.get('item_category') not in (None,'Ingredient')):
            raise ValueError('current_parent_item_reservation_unresolved')
        scope=base(goal)
        if scope in seen:raise ValueError('current_parent_item_reservation_ambiguous')
        seen.add(scope)
        matches=[key for key in reservations if key[:-1]==scope]
        if len(matches)>1:raise ValueError('parent_item_reservation_occurrence_ambiguous')
        accept_time=goal.get('accept_time')
        if matches and accept_time is not None and accept_time!=matches[0][-1]:
            raise ValueError('parent_item_reservation_occurrence_changed')
        if scope in typed_scopes:
            # The exact typed effective goal is already represented. The HUD
            # channel is supplementary, not another or replacement quantity.
            continue
        key=matches[0] if matches else scope+(accept_time,)
        reservations[key]=max(reservations.get(key,0),quantity(goal));evidence.append(deepcopy(goal))
    # Recipe materials are authored requirements, never native quest objectives
    # or deductions from current stock. Keep them through nested child goals.
    materials=snapshot.get('material_protections',[])
    if not isinstance(materials,list):raise ValueError('source_material_protections_unavailable')
    protected={}
    for row in materials:
        if row.get('item_name')!=policy['name']:continue
        if (row.get('confirmed') is not True or not _current(row,snapshot)
                or row.get('source')!='source_recipe_material_requirement'
                or row.get('requirement_origin')!='source_recipe_material' or row.get('quest_objective') is not False
                or not row.get('material_state_id') or not row.get('source_recipe')
                or not row.get('source_required_at') or not row.get('parent_scope')):
            raise ValueError('source_material_protection_unvalidated')
        key=(row['material_state_id'],policy['name'])
        protected[key]=max(protected.get(key,0),quantity(row));evidence.append(deepcopy(row))
    return sum(reservations.values())+sum(protected.values()),evidence


def _stock(inv,policy,snapshot,receipt=None):
    objective_reserved,reservation_evidence=_objective_reservations(policy,snapshot)
    if not _current(inv,snapshot) or inv.get('source') not in ('get_items','official:get_items','official-cli:get_items') or inv.get('complete') is not True:
        raise ValueError('current_complete_inventory_required')
    total=locked=reserved=0
    rows=inv.get('items')
    if not isinstance(rows,list):raise ValueError('inventory_rows_required')
    selected=[r for r in rows if isinstance(r,dict) and r.get('name')==policy['name']]
    if any(r.get('category')!='Ingredient' for r in selected):raise ValueError('ambiguous_item_category')
    for row in selected:
        if row.get('scope')!='bag':continue
        quantity,lock,reserve=row.get('quantity'),row.get('locked_quantity'),row.get('reserved_quantity',0)
        if any(type(n) is not int or n<0 for n in (quantity,lock,reserve)) or lock+reserve>quantity:
            raise ValueError('inventory_protection_unresolved')
        total+=quantity;locked+=lock;reserved+=reserve
    previous_quantity=None
    if receipt and receipt.get('phase')=='search':
        previous_quantity=receipt.get('quantity');previous_reserved=receipt.get('objective_reserved')
        if any(type(v) is not int or v<0 for v in (previous_quantity,previous_reserved)):
            raise ValueError('original_cleanup_reservation_unavailable')
        objective_reserved=max(objective_reserved,previous_reserved)
        for proof in receipt.get('objective_reservation_evidence',[]):
            if proof not in reservation_evidence:reservation_evidence.append(deepcopy(proof))
    quantity=min(max(0,total-policy['keep']),max(0,total-locked-reserved-objective_reserved))
    if previous_quantity is not None:quantity=min(quantity,previous_quantity)
    return dict(before_count=total,locked=locked,reserved=reserved,
        objective_reserved=objective_reserved,objective_reservation_evidence=reservation_evidence,quantity=quantity)


def _context(layout):
    if not isinstance(layout,dict) or layout.get('source_bound') is not True or not layout.get('source'):
        raise ValueError('current_source_canvas_required')
    context=layout.get('context')
    if isinstance(context,CanvasContext):
        if not context.profile.confirmed:raise ValueError('confirmed_canvas_profile_required')
        return context
    view=layout['viewport'];p=layout['profile']
    profile=CanvasProfile(p['reference_width'],p['reference_height'],p['minimum_width'],p['provenance'],p.get('confirmed',False))
    if not profile.confirmed:raise ValueError('confirmed_canvas_profile_required')
    viewport=Viewport(view['width'],view['height'],safe_area=Rect(*layout['safe_rect']))
    return resolve_canvas(viewport,layout['style'],layout['pc_percent'],orientation=layout['orientation'],
        profile=profile,apply_style_scale=layout['apply_style_scale'])


def _text(value):
    return ''.join(unicodedata.normalize('NFC',str(value)).split())


def matching_result_cells(name,entries,records):
    """Retained exact label-in-source-cell check. OCR boxes never become points."""
    found=[]
    for role,row in records.items():
        if not role.startswith('bag.result.cell.') or not row['visible']:continue
        roi=row['metadata']['label_roi'];tokens=[];uncertain=False
        for value,box,score in entries:
            x,y=(box[0]+box[2])/2,(box[1]+box[3])/2
            if roi[0]<=x<=roi[2] and roi[1]<=y<=roi[3]:
                uncertain=uncertain or score<.6;tokens.append((box[1],box[0],_text(value)))
        if not uncertain and ''.join(x[2] for x in sorted(tokens))==_text(name):found.append(role)
    return found


def plan_bag_recovery(snapshot,receipt=None):
    """Return a source tape, wait, or current obstruction-cleared evidence.

    snapshot.bag_recovery is supplied by BagRecoveryProvider. Receipts from any
    attempted/unknown prefix are durable; disposal is never automatically replayed.
    """
    bag=snapshot.get('bag_recovery') or {}
    if not _current(bag,snapshot) or bag.get('certainty')!='confirmed' or not bag.get('frame_id'):
        return _wait('current_bag_provider_unavailable',receipt,['current_bag_state','current_source_canvas'])
    owner=[snapshot.get('character_id'),snapshot.get('session_id')]
    if receipt and receipt.get('owner')!=owner:return _wait('bag_recovery_owner_changed',receipt)
    if receipt:
        try:
            if _stamp(bag['observed_at'])<=_stamp(receipt['observed_at']):
                return _wait('bag_effect_predates_intent',receipt)
        except (KeyError,TypeError,ValueError):return _wait('invalid_bag_recovery_receipt',receipt)
    if bag.get('bag_blocked') is False:
        return dict(status='recovered',reason='current_bag_obstruction_cleared',proposal=None,
            next_receipt=deepcopy(receipt),prior_effect_confirmed=bool(receipt),effect_evidence=[{
                k:bag[k] for k in ('source','observed_at','character_id','session_id','frame_id','bag_blocked')}],
            missing=[],sent_to_game=False,removed_quantity_confirmed=False)
    if bag.get('bag_blocked') is not True and not (receipt and receipt.get('obstruction_evidence')):
        return _wait('current_bag_obstruction_unknown',receipt)
    try:
        policy=_policy(bag.get('policy'));policy_key=_hash(policy)
        if receipt and receipt.get('policy_key')!=policy_key:raise ValueError('pending_cleanup_policy_changed')
        context=_context(bag.get('layout'))
        source_rows=bag_geometry(context,'bag.cleanup')
        action_rows=[]
        safe=[context.safe_rect.left,context.safe_rect.top,context.safe_rect.width,context.safe_rect.height]
        interval=bag.get('input_interval_seconds')
        if type(interval) not in (float,int) or not math.isfinite(interval) or not .1<=interval<=60.25:
            raise ValueError('current_user_input_interval_required')
        def click(scene,role,roles=None):
            row=(source_rows if scene=='bag.cleanup' else bag_geometry(context,scene,ordered_roles=roles))[role]
            if not row['visible'] or not row['enabled']:raise ValueError('source_target_not_visible:'+role)
            action_rows.append(dict(kind='click',role=role,point=list(row['rect'].center)))
        def text(value,scope):action_rows.append(dict(kind='bag_text',role=scope,text=value,text_scope=scope))
        def finish(phase,details,prior=False):
            intervals=bag.get('input_intervals_seconds')
            if intervals is not None and (not isinstance(intervals,list) or len(intervals)!=len(action_rows)-1 or
                    any(type(v) not in (int,float) or not math.isfinite(v) or not .1<=v<=60.25 for v in intervals)):
                raise ValueError('current_sampled_input_intervals_required')
            for index,row in enumerate(action_rows):
                row['delay_before_seconds']=0 if index==0 else float(intervals[index-1] if intervals is not None else interval)
            refs=['data/arbeit-bag-source/manifest.json','data/arbeit-bag-source/mini26/bag_cleanup.py',
                'data/arbeit-bag-source/mini26/resources/ui-rules/bag-cleanup-geometry.json']
            saved=dict(owner=owner,policy_key=policy_key,policy=policy,phase=phase,dispatch_state='prepared',
                observed_at=bag['observed_at'],frame_id=bag['frame_id'],
                window_signature=bag.get('window_signature'),layout=_layout_document(context,bag['layout']['source']),
                obstruction_evidence=(deepcopy(bag.get('obstruction_evidence')) or
                    {k:bag[k] for k in ('source','observed_at','character_id','session_id','frame_id','bag_blocked')}
                    if bag.get('bag_blocked') is True else deepcopy((receipt or {}).get('obstruction_evidence'))),**details)
            proposal=dict(status='ready',actions=action_rows,source_refs=refs,observed_at=bag['observed_at'],
                frame_id=bag['frame_id'],session_id=snapshot['session_id'],expected_effect='bag.'+phase,
                geometry_provenance={'runtime_verified':False,'live_test_performed':False,
                    'basis':'retained source geometry; current canvas is separately supplied'},
                semantic_scope='explicit material cleanup only; input is not removal proof')
            return dict(status='action',reason='source_bag_recovery_tape',proposal=proposal,next_receipt=saved,
                prior_effect_confirmed=prior,effect_evidence=[],missing=[],sent_to_game=False)
        phase=(receipt or {}).get('phase');stage=bag.get('stage')
        if phase in ('dispose','close'):
            # The original receipt only authorizes the close tail, never another sale.
            if stage=='bag' and bag.get('owned_bag') is True and phase!='close':
                click('bag.cleanup','bag.close')
                return finish('close',{'quantity':receipt['quantity'],'before_count':receipt['before_count']},True)
            return _wait('bag_cleanup_input_finished_effect_unconfirmed',receipt,['current_bag_obstruction_cleared'])
        stock=_stock(snapshot.get('inventory'),policy,snapshot,receipt)
        if stock['quantity']<=0:return _wait('cleanup_policy_has_no_disposable_surplus',receipt)
        rule=_rule(policy['name'])
        if rule['action']=='unavailable':return _wait('material_disposal_unavailable',receipt)
        if phase=='search':
            if stage!='search_results' or bag.get('search_name')!=policy['name'] or bag.get('category')!='Ingredient':
                return _wait('exact_search_result_pending',receipt,['exact_search_results'])
            # Original source observes only this boundary before the consuming tape.
            matches=matching_result_cells(policy['name'],bag.get('search_results',[]),source_rows)
            if len(matches)!=1:return _wait('exact_material_result_not_unique',receipt,['unique_exact_material_cell'])
            click('bag.cleanup',matches[0]);click('bag.detail','bag.'+rule['action'],rule['detail_roles'])
            if rule['action']=='sell' and rule['warning']:click('bag.warning','bag.warning.confirm')
            click('bag.sale','bag.quantity');text(str(stock['quantity']),'bag.quantity')
            click('bag.sale','bag.confirm')
            if rule['action']=='discard' and rule['warning']:click('bag.warning','bag.warning.confirm')
            # Parent journals the whole intent before the tape. Unknown prefixes
            # cannot reach this consuming sequence again on a later invocation.
            click('bag.cleanup','bag.close')
            return finish('dispose',dict(stock,action=rule['action']),True)
        if receipt:return _wait('unknown_bag_recovery_receipt',receipt)
        if stage!='field':return _wait('owned_field_required_before_cleanup',receipt,['current_field_hud'])
        click('bag.cleanup','bag.open');click('bag.cleanup','bag.items')
        reset=source_rows['bag.categories.reset']
        for step in reset['metadata']['scroll_steps']:
            delta=step['delta']
            while delta:
                chunk=min(600,delta)
                action_rows.append(dict(kind='scroll',role='bag.categories.reset',point=list(reset['rect'].center),delta=chunk,safe_rect=safe))
                delta-=chunk
        for role in ('bag.materials','bag.search','bag.reset'):click('bag.cleanup',role)
        action_rows.append(dict(kind='key',role='bag.search_focus',key=source_rows['bag.reset']['metadata']['search_focus_key']))
        text(policy['name'],'bag.search_name');click('bag.cleanup','bag.apply')
        return finish('search',stock)
    except (KeyError,TypeError,ValueError,OSError) as exc:
        return _wait('bag_recovery_evidence_missing:'+str(exc),receipt)


class BagRecoveryProvider:
    """Read supplied RAM pixels; captures and input remain the caller's job."""
    def __init__(self,policy=None,*,current_reader=None,ocr=None,receipt_reader=None,
                 continuity_reader=None,input_interval_reader=None):
        self.policy=deepcopy(policy)
        self.current_reader=current_reader or CurrentBagReader(policy,ocr=ocr,receipt_reader=receipt_reader,
            continuity_reader=continuity_reader,input_interval_reader=input_interval_reader)

    def __call__(self,*,window,node,image,frame,snapshot):
        result=self.current_reader(window=window,node=node,image=image,frame=frame,snapshot=snapshot)
        if not isinstance(result,dict) or result.get('ok') is not True:return result
        value=deepcopy(result.get('snapshot',result));value['policy']=deepcopy(self.policy)
        required=('source','observed_at','character_id','session_id','frame_id','certainty','stage')
        if any(not value.get(k) for k in required) or value['frame_id']!=frame.get('frame_id',frame.get('sha256')):
            return dict(ok=False,reason='current_bag_frame_binding_missing',missing=list(required))
        return dict(ok=True,snapshot=value,planner_ready=value.get('stage')!='unknown',
            missing=result.get('missing',[]),live_verified=False)


def _layout_document(context,source):
    p=context.profile;s=context.safe_rect
    return dict(source=source,source_bound=True,viewport={'width':context.viewport.width,'height':context.viewport.height},
        safe_rect=[s.left,s.top,s.width,s.height],style=context.style,pc_percent=context.percent,
        orientation=context.orientation,apply_style_scale=context.style_scale_applied,
        profile=dict(reference_width=p.reference_width,reference_height=p.reference_height,
            minimum_width=p.minimum_width,provenance=p.provenance,confirmed=p.confirmed))


def _validated_pixel_rows(raw,image):
    """Use the existing OfflineOCR row protocol; validate before semantic use."""
    rows=[]
    for row in raw:
        try:
            box=row['box'];score=row['score']
            if (type(row['text']) is not str or type(score) not in (float,int) or not math.isfinite(score) or not 0<=score<=1 or
                len(box)!=4 or any(type(v) not in (float,int) or not math.isfinite(v) for v in box) or
                not (0<=box[0]<box[2]<=image.width and 0<=box[1]<box[3]<=image.height)):continue
            rows.append(dict(text=row['text'],score=float(score),box=list(box)))
        except (KeyError,TypeError,ValueError):continue
    return rows


def _warning(rows,width,height):
    # Existing main automation/bag_capacity.py:12-20. An early weight warning
    # and the absence of a full warning never prove blocked=False.
    phrases=('가방이가득','가방무게가초과','가방의무게가초과','소지무게를초과',
        '소지무게가초과','가방공간이부족','가방이무거워채집','가방이가득차서채집')
    for row in rows:
        b=row['box'];x,y=(b[0]+b[2])/2/width,(b[1]+b[3])/2/height
        if row['score']>=.9 and .2<=x<=.85 and (.07<=y<=.32 or .78<=y<=.94):
            if any(p in _text(row['text']) for p in phrases):return row
    return None


def read_bag_obstruction(image,rows,*,field_confirmed=False,ui_percent=None):
    """Actual warning or source-matched HUD ring; absence remains unknown.

    Retains main bag_capacity.py glyph/ring thresholds. The native glyph proof
    is restricted to the retained 16:9/UI70 field; other layouts need new source
    evidence. No inventory total, capacity counter or action receipt is read.
    """
    warning=_warning(rows,image.width,image.height)
    if warning:return dict(bag_blocked=True,basis='current_exact_bag_warning',observed=warning)
    unknown=dict(bag_blocked=None,basis='bag_indicator_unmeasured')
    if not field_confirmed or ui_percent!=70 or image.width*9!=image.height*16 or image.width<640:
        return dict(unknown,missing=['current_16_9_UI70_field_for_retained_bag_ring'])
    if not any(_text(t['text']) in ('퀘스트','퀘스트+','퀘스트＋') and t['score']>=.7 and
            (t['box'][0]+t['box'][2])/2>image.width*.7 for t in rows):
        return dict(unknown,missing=['current_quest_HUD_ring_context'])
    try:
        import cv2
        import numpy as np
        from PIL import Image
        source=root()/'data/arbeit-bag-source'
        manifest=json.loads((source/'observation-manifest.json').read_text(encoding='utf-8'))
        w,h=image.size;x0,y0,x1,y1=round(w*.86),round(h*.89),round(w*.923),round(h*.984)
        rgb=np.asarray(image.crop((x0,y0,x1,y1)).convert('RGB'));scale=w/3840;matches=[]
        for name in ('bag-capacity-glyph.png','bag-capacity-full-glyph.png'):
            path=source/'main'/name
            expected=next(row['copy_sha256'] for row in manifest if _path_name(row['copy'])==name)
            if hashlib.sha256(path.read_bytes()).hexdigest()!=expected.lower():raise ValueError('bag_glyph_source_hash_changed')
            with Image.open(path) as frame:glyph=np.asarray(frame.convert('L'))
            glyph=cv2.resize(glyph,None,fx=scale,fy=scale,interpolation=cv2.INTER_AREA)
            if min(glyph.shape)<2 or any(a<b for a,b in zip(rgb.shape[:2],glyph.shape)):return unknown
            scores=cv2.matchTemplate(cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY),glyph,cv2.TM_CCOEFF_NORMED)
            _,score,_,point=cv2.minMaxLoc(scores);matches.append((score,point,name))
        score,point,name=max(matches)
        if score<.82:return dict(unknown,missing=['current_bag_glyph_match'])
        cx,cy=point[0]+40*scale,point[1]+48*scale
        yy,xx=np.indices(rgb.shape[:2]);radius=np.hypot(xx-cx,yy-cy)/scale
        ring=(radius>=65)&(radius<=81);hsv=cv2.cvtColor(rgb,cv2.COLOR_RGB2HSV)
        vivid=ring&(hsv[:,:,1]>140)&(hsv[:,:,2]>130)
        if vivid.sum()<ring.sum()*.20:return dict(unknown,missing=['current_bag_capacity_ring_visible'])
        red=vivid&((hsv[:,:,0]<10)|(hsv[:,:,0]>173));amber=vivid&(hsv[:,:,0]>=12)&(hsv[:,:,0]<40)
        value=True if red.sum()>vivid.sum()*.65 else False if amber.sum()>vivid.sum()*.65 else None
        return dict(bag_blocked=value,basis='current_source_bag_glyph_and_ring',glyph=name,match_score=float(score),
            ring_red_fraction=float(red.sum()/vivid.sum()),ring_amber_fraction=float(amber.sum()/vivid.sum()),
            supported_profile='16:9/UI70 current field only',source_ref='data/arbeit-bag-source/observation-manifest.json')
    except (OSError,ValueError,ImportError,StopIteration) as exc:
        return dict(unknown,missing=[str(exc)])


def _path_name(path):
    # Source manifests preserve Windows paths even in an offline Unix fixture.
    return str(path).replace('\\','/').rsplit('/',1)[-1]


class CurrentBagReader:
    """Concrete source-region pixel reader; never derives capacity from counts.

    Field geometry is obtained from the caller's current HUD/source observation.
    During owned menu tapes, the durable receipt retains that geometry only while
    the same window and uninterrupted input lease are explicitly confirmed.
    """
    def __init__(self,policy=None,*,ocr=None,receipt_reader=None,continuity_reader=None,input_interval_reader=None):
        self.policy=deepcopy(policy);self.ocr=ocr;self.receipt_reader=receipt_reader
        self.continuity_reader=continuity_reader;self.input_interval_reader=input_interval_reader

    def __call__(self,*,window,node,image,frame,snapshot):
        missing=[]
        try:
            if image is None or not frame.get('captured_at') or not frame.get('window_signature'):
                raise ValueError('supplied_current_bag_frame_required')
            if not snapshot.get('character_id') or not snapshot.get('session_id'):
                raise ValueError('current_bag_owner_required')
            if (frame.get('character_id')!=snapshot['character_id'] or frame.get('session_id')!=snapshot['session_id']):
                raise ValueError('bag_frame_owner_mismatch')
            if window is not None:
                if tuple(image.size)!=(window.client_rect[2]-window.client_rect[0],window.client_rect[3]-window.client_rect[1]):
                    raise ValueError('bag_frame_window_size_mismatch')
                if frame['window_signature']!=json.dumps(window.identity):raise ValueError('bag_frame_window_identity_mismatch')
            value=dict(source='current supplied pixels + retained bag source regions',observed_at=frame['captured_at'],
                character_id=snapshot['character_id'],session_id=snapshot['session_id'],
                frame_id=frame.get('frame_id',frame.get('sha256')),window_signature=frame['window_signature'],
                certainty='confirmed',stage='unknown',bag_blocked=None,owned_bag=False,search_results=[],
                scope='pixel observation; no capacity count inference',game_input_sent=False)
            child=self.receipt_reader() if callable(self.receipt_reader) else None
            receipt=(child or {}).get('planner_receipt') or {}
            # An uncertain disposal may close only its observed bag tail. It
            # cannot acquire search ownership and start a consuming tape.
            owned=bool(child and (child.get('status')=='sent_unconfirmed' or
                (child.get('status')=='unknown' and receipt.get('phase')=='dispose')) and
                receipt.get('owner')==[snapshot['character_id'],snapshot['session_id']] and
                receipt.get('window_signature')==frame['window_signature'] and
                callable(self.continuity_reader) and self.continuity_reader() is True)
            current=snapshot.get('current_context') or {}
            layout=None
            if current.get('ok') and current.get('field_hud') and current.get('profile',{}).get('visual_confirmed'):
                if current.get('frame_observed_at')!=frame['captured_at']:
                    raise ValueError('current_canvas_frame_binding_missing')
                p=current['profile'];l,t,r,b=current['safe_rect']
                # 'pc' expresses the measured effective percent for calculation;
                # it is not a claim about the player's unknown style enum.
                layout=dict(source=current['source'],source_bound=True,viewport={'width':image.width,'height':image.height},
                    safe_rect=[l,t,r-l,b-t],style='pc',pc_percent=p['percent'],orientation=p['orientation'],apply_style_scale=True,
                    profile=dict(reference_width=p['reference_width'],reference_height=p['reference_height'],
                        minimum_width=p['minimum_width'],provenance=p['source']+'; current HUD glyph fit',confirmed=True))
                value['stage']='field'
            elif owned and receipt.get('layout'):
                layout=deepcopy(receipt['layout'])
                if layout.get('viewport')!={'width':image.width,'height':image.height}:raise ValueError('owned_bag_canvas_size_changed')
            if layout is None:missing.append('current_canvas_or_uninterrupted_owned_canvas')
            else:value['layout']=layout
            reused=(isinstance(frame.get('ocr_rows'),list) and frame.get('ocr_rows_frame_id')==value['frame_id'])
            if reused:
                rows=_validated_pixel_rows(frame['ocr_rows'],image)
            else:
                if self.ocr is None:
                    from .arbeit_targets import OfflineOCR
                    self.ocr=OfflineOCR()
                rows=_validated_pixel_rows(self.ocr.read(image),image)
            if layout:
                records=bag_geometry(_context(layout),'bag.cleanup');header=records['bag.header']['rect']
                headings=[row for row in rows if row['score']>=.9 and _text(row['text'])=='가방' and
                    header.left<=(row['box'][0]+row['box'][2])/2<header.right and
                    header.top<=(row['box'][1]+row['box'][3])/2<header.bottom]
                if len(headings)==1:
                    value['stage']='bag';value['owned_bag']=owned
                    if owned and child.get('status')=='sent_unconfirmed' and receipt.get('phase')=='search':
                        policy=_policy(self.policy)
                        if receipt.get('policy_key')!=_hash(policy):raise ValueError('owned_search_policy_changed')
                        entries=[(row['text'],[row['box'][0]/image.width,row['box'][1]/image.height,
                            row['box'][2]/image.width,row['box'][3]/image.height],row['score']) for row in rows]
                        exact=matching_result_cells(policy['name'],entries,records)
                        value['search_results']=entries
                        if len(exact)==1:
                            value.update(stage='search_results',search_name=policy['name'],category='Ingredient',
                                search_binding='sent owned material search tape plus exact source-cell label')
                        else:missing.append('unique_exact_material_search_cell')
                elif len(headings)>1:missing.append('unique_bag_header')
            obstruction=read_bag_obstruction(image,rows,field_confirmed=value['stage']=='field',
                ui_percent=(layout or {}).get('pc_percent'))
            value['bag_blocked']=obstruction['bag_blocked']
            value['obstruction_observation']=obstruction
            if type(obstruction['bag_blocked']) is bool:
                value['obstruction_evidence']=dict(source=obstruction['basis'],observed_at=frame['captured_at'],
                    character_id=value['character_id'],session_id=value['session_id'],frame_id=value['frame_id'],
                    bag_blocked=obstruction['bag_blocked'],observation=obstruction)
            else:missing.extend(obstruction.get('missing',['current_bag_capacity_indicator']))
            if value['stage']=='unknown':missing.append('current_field_or_bag_header')
            if callable(self.input_interval_reader):value['input_interval_seconds']=self.input_interval_reader()
            else:missing.append('current_user_input_interval')
            return dict(ok=True,snapshot=value,missing=missing,live_verified=False,
                input_source='shared-current-frame-ocr' if reused else
                    'actual-pixel-ocr' if type(self.ocr).__name__=='OfflineOCR' else 'injected-pixel-reader')
        except (KeyError,TypeError,ValueError,OSError,ImportError,AttributeError) as exc:
            return dict(ok=False,reason='current_bag_observation_unavailable',missing=missing+[str(exc)],live_verified=False)


# Source geometry below is statically adapted from the manifest-listed mini26
# copies. Only imports/resource ownership changed; no current state is inferred.


def _number(value, name, *, positive=False):
    if (type(value) not in (int, float) or not math.isfinite(value)
            or value < 0 or (positive and value == 0)):
        raise LayoutError("invalid bag category scroll " + name)
    return float(value)


def category_scroll_reset(viewport, widths_ui, gap_ui, scale, rule):
    """Return a physical wheel-recipient Rect and immutable-plan metadata.

    Enough positive notches are prepared to cover the entire legal scroll
    range, so every initial offset (including zero) reaches the same boundary.
    The serialized Elastic/inertia settings do not add overshoot: mouse-wheel
    dispatch uses checkBoundary=True and clears velocity before/after input.
    """
    if not isinstance(viewport, Rect) or viewport.width <= 0 or viewport.height <= 0:
        raise LayoutError("bag category scroll viewport required")
    scale = _number(scale, "scale", positive=True)
    gap_ui = _number(gap_ui, "gap")
    if not isinstance(widths_ui, (tuple, list)) or not widths_ui:
        raise LayoutError("bag category ordered widths required")
    widths = [_number(width, "category width", positive=True) for width in widths_ui]
    speed = _number(rule["scroll_speed"], "speed", positive=True)
    quantum = rule["wheel_quantum"]
    if (type(quantum) is not int or quantum != 120
            or rule["direction_constraint"] != 2
            or tuple(rule["raw_event_clamp"]) != (-1, 1)):
        raise LayoutError("bag category horizontal source wheel contract required")
    content_width = sum(widths) + gap_ui * (len(widths) - 1)
    max_offset = max(0., content_width - viewport.width / scale)
    notches = math.ceil(max_offset / speed)
    # Stay inside the actual header ScrollSlot, never over Search/Sort controls.
    recipient = Rect(viewport.left + viewport.width / 4,
                     viewport.top + viewport.height / 4,
                     viewport.width / 2, viewport.height / 2)
    metadata = dict(
        geometry_kind="source-scroll-parent-wheel-recipient",
        input_kind="scroll-only", source_parent_bounds=viewport.bounds,
        source_scroll_slot=rule["source_scroll_slot"],
        source_layout_rule=rule["source_layout_rule"],
        content_width_ui=content_width, viewport_width_ui=viewport.width / scale,
        max_scroll_x=max_offset * scale, max_scroll_x_ui=max_offset,
        target_scroll_x=0., reset_boundary="left", category_label_widths=widths,
        wheel_quantum=quantum, wheel_step_ui=speed, source_scroll_speed=speed,
        wheel_event_clamp=tuple(rule["raw_event_clamp"]),
        wheel_boundary_clamp=True, wheel_inertia=False,
        wheel_input_mode="notches", wheel_source=rule["wheel_source"],
        scroll_steps=([dict(delta=quantum * notches, notches=notches, direction="left")]
                      if notches else []))
    return recipient, metadata


def bag_detail_layouts():
    data = json.loads((_resource_dir() / "ui-rules/bag-cleanup-geometry.json").read_bytes())
    return tuple(tuple(value) for value in data["detail"]["layouts"])


def _detail_flow(detail, roles, available):
    """Source FlowLayoutGroup: ordered wrapping, centered rows, bottom group."""
    lines = [[]]
    width = 0
    for role in roles:
        caption = detail["labels"][role]
        text_advance = sum(detail["space_advance"] if character == " " else detail["hangul_advance"]
                           for character in caption)
        button_width = max(detail["minimum_width"], detail["content_padding_x"]
            +detail["icon_width"]+(detail["content_gap"]+text_advance
            *detail["font_size"]/detail["units_per_em"]
                if caption else 0))
        if button_width > available:
            raise LayoutError("bag detail button does not fit the source parent")
        next_width = width+(detail["button_gap"] if lines[-1] else 0)+button_width
        if lines[-1] and next_width > available:
            lines.append([])
            width = 0
        width += (detail["button_gap"] if lines[-1] else 0)+button_width
        lines[-1].append((role,button_width))
    return lines


def bag_geometry(context, scene, *, ordered_roles=None):
    raw = (_resource_dir() / "ui-rules/bag-cleanup-geometry.json").read_bytes()
    data = json.loads(raw)
    digest = hashlib.sha256(raw).hexdigest()
    if not isinstance(data.get("revision"), str):
        raise LayoutError("bag layout revision required")
    safe, scale = context.safe_rect, context.scale
    landscape = context.orientation == "landscape"
    panel = data["panel"]
    left = safe.right - (panel["right"] + panel["width"]) * scale if landscape else safe.center[0] - panel["width"] * scale / 2
    top = safe.top + panel["landscape_top" if landscape else "portrait_top"] * scale
    rows = {}

    def add(role, rect, *, enabled=True, crop=None, **metadata):
        crop = (rect if crop is None else crop).intersection(safe)
        visible = safe.contains(rect)
        rows[role] = dict(rect=rect, ocr_rect=crop, visible=visible,
            enabled=enabled and visible, metadata=dict(scene=scene,
                revision=data["revision"], rule_sha256=digest,
                source=data["provenance"], coordinate_space="physical-client",
                runtime_click_approved=False, **metadata))

    def local(role, x, y, width, height, **metadata):
        add(role, Rect(left+x*scale, top+y*scale, width*scale, height*scale), **metadata)

    if scene == "bag.cleanup":
        hud = data["hud"]
        size = hud["size"] * scale
        prefix = "landscape_" if landscape else "portrait_"
        add("bag.open", Rect(safe.right-hud[prefix+"right"]*scale-size,
            safe.bottom-hud[prefix+"bottom"]*scale-size, size, size))
        tabs = data["main_tabs"]
        add("bag.items", Rect(left+(panel["width"]-tabs["width"])*scale/2,
            safe.bottom-(tabs["bottom"]+tabs["height"])*scale,
            tabs["width"]*scale, tabs["height"]*scale))
        header = data["header"]
        local("bag.search", header["left"], header["top"]+(header["height"]-header["search_width"])/2,
            header["search_width"], header["search_width"])
        categories = data["categories"]
        widths = [max(categories["min_text_width"], len(label)*categories["hangul_advance"]
            *categories["font_size"]/categories["units_per_em"])+categories["padding"]
            +(categories["dropdown_extra"] if i in categories["dropdown_indexes"] else 0)
            for i, label in enumerate(categories["labels"])]
        index = categories["material_index"]
        start = header["left"]+header["search_width"]+header["gap"]
        scroll_width = panel["width"]-2*header["left"]-header["search_width"]-header["sort_width"]-2*header["gap"]
        scroll_viewport = Rect(left+start*scale, top+header["top"]*scale,
            scroll_width*scale, header["height"]*scale)
        pointer, scroll_metadata = category_scroll_reset(scroll_viewport, widths,
            categories["gap"], scale, data["category_scroll"])
        add("bag.categories.reset", pointer, **scroll_metadata)
        material_left = start+sum(widths[:index])+index*categories["gap"]
        local("bag.materials", material_left, header["top"]+(header["height"]-categories["height"])/2,
            widths[index], categories["height"], category_id="Item_Ingredient",
            initial_category="Item_All", category_label_widths=widths)
        close = data["close"]
        add("bag.close", Rect(safe.right-(close["right"]+close["size"])*scale,
            safe.top+close["top"]*scale, close["size"]*scale, close["size"]*scale))
        # Header is observation only. Its actual localized title is not assumed.
        add("bag.header", Rect(safe.left, safe.top, safe.width, panel["landscape_top"]*scale), enabled=False)
        grid = data["grid"]
        viewport = Rect(left+grid["left"]*scale, top+grid["top"]*scale,
            (panel["width"]-grid["left"]-grid["right"])*scale,
            max(0, safe.bottom-grid["bottom"]*scale-(top+grid["top"]*scale)))
        add("bag.result.roi", viewport, enabled=False)
        stride_x = grid["cell_width"]+grid["gap_x"]
        stride_y = grid["cell_height"]+grid["gap_y"]
        available = panel["width"]-grid["left"]-grid["right"]-grid["padding_left"]-grid["padding_right"]
        columns = max(0, math.floor((available+grid["gap_x"])/stride_x))
        count_rows = max(0, math.floor((viewport.height/scale+grid["gap_y"])/stride_y))
        for row in range(count_rows):
            for column in range(columns):
                x = viewport.left+(grid["padding_left"]+column*stride_x)*scale
                y = viewport.top+row*stride_y*scale
                cell = Rect(x, y, grid["cell_width"]*scale, grid["cell_height"]*scale)
                label = Rect(x+grid["label_left"]*scale, y+grid["label_top"]*scale,
                    grid["label_width"]*scale, grid["label_height"]*scale)
                add(f"bag.result.cell.{row*columns+column}", cell, crop=label,
                    label_roi=context.viewport.rect_to_normalized(label), row=row, column=column,
                    item_name_only=True, source_item_prefab=data["provenance"]["inventory_item"])
        f = data["filter"]
        filter_left = safe.right-(f["right"]+f["width"])*scale if landscape else safe.center[0]-f["width"]*scale/2
        button_width = (f["buttons_width"]-f["button_gap"])/2
        buttons_left = filter_left+(f["width"]-f["buttons_width"])*scale/2
        y = safe.bottom-(f["bottom"]+f["button_height"])*scale
        for role, offset in (("bag.reset", 0), ("bag.apply", button_width+f["button_gap"])):
            add(role, Rect(buttons_left+offset*scale, y, button_width*scale, f["button_height"]*scale),
                search_focus_key=data["search_focus_key"], search_focus_evidence=data["search_focus_evidence"])
        if landscape:
            x = f["padding_x"]+f["flow_padding_x"]+f["search_padding_x"]+f["text_input_left"]
            y = f["top"]+f["padding_top"]+f["search_title_height"]+f["flow_gap"]+(f["search_height"]-f["text_input_height"])/2
            width = f["width"]-2*(f["padding_x"]+f["flow_padding_x"]+f["search_padding_x"])-f["text_input_left"]
            add("bag.name", Rect(filter_left+x*scale, safe.top+y*scale, width*scale, f["text_input_height"]*scale))
    elif scene == "bag.sale":
        sale = data["sale"]
        sale_left = safe.right-(sale["right"]+sale["panel_width"])*scale if landscape else safe.center[0]-sale["panel_width"]*scale/2
        sale_top = safe.bottom-sale["panel_height"]*scale
        content_width = sale["panel_width"]-2*sale["buttons_margin_x"]
        input_width = content_width-sale["spinner_width_delta"]-sale["input_width_delta"]
        input_y = sale["contents_top"]+sale["title_height"]+sale["content_gap"]+(sale["spinner_height"]-sale["input_height"])/2
        add("bag.quantity", Rect(sale_left+(sale["panel_width"]-input_width)*scale/2,
            sale_top+input_y*scale, input_width*scale, sale["input_height"]*scale),
            source_field="TextFieldSlot -604643723112722992; spinner/InputField -6087022360572682939")
        add("bag.sale.title", Rect(sale_left+sale["buttons_margin_x"]*scale,
            sale_top+sale["contents_top"]*scale, content_width*scale, sale["title_height"]*scale),
            enabled=False, string_table_id="UI_DetailCardUI_Popup_QuantitySelect_00001")
        width = (sale["panel_width"]-2*sale["buttons_margin_x"]-sale["button_gap"])/2
        add("bag.confirm", Rect(sale_left+(sale["buttons_margin_x"]+width+sale["button_gap"])*scale,
            safe.bottom-(sale["bottom"]+sale["button_height"])*scale,
            width*scale, sale["button_height"]*scale))
    elif scene == "bag.detail":
        detail = data["detail"]
        layouts = [tuple(value) for value in detail["layouts"]]
        delta = detail["landscape_width_delta" if landscape else "portrait_width_delta"]
        available = safe.width/scale-delta
        selected = tuple(ordered_roles or ())
        if selected not in layouts:
            raise LayoutError("bag detail layout has no retained source binding")
        lines = _detail_flow(detail,selected,available)
        for index, line in enumerate(lines):
            total = sum(width for _,width in line)+detail["button_gap"]*(len(line)-1)
            cursor = safe.center[0]-total*scale/2
            row_top = safe.bottom-(detail["padding_bottom"]+detail["button_height"]
                +(len(lines)-index-1)*(detail["button_height"]+detail["button_gap"]))*scale
            for role, width in line:
                rect = Rect(cursor,row_top,width*scale,detail["button_height"]*scale)
                name = "bag."+role.lower()
                label = detail["labels"][role]
                add(name,rect,expected_label=label,caption=label,icon_only=not bool(label),
                    ordered_roles=selected,button_type=role,row=index,row_count=len(lines),
                    source_label_key=detail["label_keys"].get(role),
                    source_prefab="9afc338ee9fbce4265352bb2",price_fields_visible=False)
                cursor += (width+detail["button_gap"])*scale
    elif scene == "bag.warning":
        warning = data["warning"]
        width = (warning["width"]-2*warning["padding_x"]-warning["button_gap"])/2
        add("bag.warning.confirm", Rect(safe.center[0]+warning["button_gap"]*scale/2,
            safe.bottom-(warning["bottom"]+warning["padding_bottom"]+warning["button_height"])*scale,
            width*scale, warning["button_height"]*scale), native_binding=warning["source"])
    else:
        raise LayoutError("unknown bag scene: " + scene)
    return rows
