"""Exact current Channel/Region operands -> source region -> current map point.

The read helper uses an ALREADY OPEN, version-pinned MapMemorySession. It opens
no process, executes no client method, and reads only one terrain sample per
goal. Pure planners never treat a center, map selection or distance as a quest
effect. Native quest effect ownership stays with objective_binding.
"""
from copy import deepcopy
from datetime import datetime, timezone
from functools import lru_cache
import hashlib
import json
import math
import struct

from .paths import root
from .typed_objective_operands import SCOPE

SOURCE='current_MapPositionCalculator_world_position_to_map_position'
SOURCE_REF='data/arbeit-region-movement.json'
SOURCE_HASH='35a7606f568e9d82937e3ce2322987f9009fbdea7e0a29ea08abe7523f5e7e7d'


class RegionUnavailable(ValueError):pass


@lru_cache(maxsize=1)
def rules():
    raw=(root()/SOURCE_REF).read_bytes()
    if hashlib.sha256(raw).hexdigest()!=SOURCE_HASH:raise RegionUnavailable('region_source_bundle_hash_changed')
    return json.loads(raw)


def _f(value):
    if type(value) not in (int,float) or not math.isfinite(value):raise RegionUnavailable('nonfinite_region_coordinate')
    return struct.unpack('<f',struct.pack('<f',value))[0]


def _pair(value):
    if not isinstance(value,(list,tuple)) or len(value)!=2:raise RegionUnavailable('region_coordinate_pair_required')
    return [_f(v) for v in value]


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


def _time(value):
    value=datetime.fromisoformat(value.replace('Z','+00:00'))
    if value.tzinfo is None:raise RegionUnavailable('region_observation_timezone_missing')
    return value


def _request_key(normalized):
    return _digest({k:normalized.get(k) for k in ('scope','data_type','operands','constraints','source_version','session_id','character_id')})


def _polygon_contains(points,point):
    """Pinned Core.Mathematics.Polygon.Contains odd crossing test.

    Exact y-equality endpoint branches precede the strict opposite-side test.
    This is STATIC geometry only, never a current character/quest effect.
    """
    px,py=_pair(point);crossings=0
    for i,(x0,y0) in enumerate(points):
        x1,y1=points[(i+1)%len(points)]
        if y0==py:
            if y1>py and x0>px:crossings+=1
        elif y1==py:
            if y0>py and x1>px:crossings+=1
        elif _f(_f(y1-py)*_f(y0-py))<0:
            edge=_f(_f(_f(_f(x1-x0)*_f(py-y0))/_f(y1-y0))+x0)
            if edge>px:crossings+=1
    return crossings%2==1


def region_route_point(shape):
    """Deterministic navigation point from exact serialized region geometry.

    Native GetCenter is kept separately. A ring center or concave polygon AABB
    center may lie OUTSIDE its region, so those centers are never used as an
    arrival assertion or blindly chosen as the walk destination. Interior
    selection is local geometry planning, verified by the pinned predicates.
    """
    name=shape.get('$type');center=None;point=None;strategy='source_GetCenter'
    if name in ('RegionPointShape','RegionCircleShape','RegionRotatedRectangleShape','RegionRingShape'):
        center=_pair(shape.get('center'));point=center
        if name=='RegionCircleShape' and _f(shape.get('radius'))<0:raise RegionUnavailable('invalid_region_circle_radius')
        if name=='RegionRotatedRectangleShape' and min(_f(shape.get('width')),_f(shape.get('height')))<0:
            raise RegionUnavailable('invalid_region_rectangle_size')
        if name=='RegionRingShape':
            inner=_f(shape.get('innerRadius'));outer=_f(shape.get('outerRadius'))
            if not 0<=inner<outer:raise RegionUnavailable('invalid_region_ring_radii')
            radius=_f(_f(inner+outer)*.5);point=[_f(center[0]+radius),center[1]]
            dist=_f(_f(point[0]-center[0])**2)
            if not _f(inner*inner)<dist<_f(outer*outer):raise RegionUnavailable('ring_interior_float_precision_unavailable')
            strategy='source_Ring_Contains_verified_interior_radius'
    elif name=='RegionAxisAlignedRectangleShape':
        low=_pair(shape.get('min'));high=_pair(shape.get('max'))
        if any(a>b for a,b in zip(low,high)):raise RegionUnavailable('invalid_region_rectangle_bounds')
        center=[_f(_f(a+b)*.5) for a,b in zip(low,high)];point=center
    elif name in ('RegionPolygonShape','RegionConvexPolygonShape'):
        raw=shape.get('points')
        if not isinstance(raw,list) or not 3<=len(raw)<=512:raise RegionUnavailable('region_polygon_vertices_outside_bound')
        points=[_pair(p) for p in raw]
        low=[min(p[i] for p in points) for i in (0,1)];high=[max(p[i] for p in points) for i in (0,1)]
        center=[_f(_f(a+b)*.5) for a,b in zip(low,high)];point=center
        if not _polygon_contains(points,center):
            # No guessed marker/quest-specific point: intersect the actual
            # polygon on open horizontal bands and verify the chosen midpoint.
            ys=sorted({p[1] for p in points});candidates=[]
            for bottom,top in zip(ys,ys[1:]):
                y=_f(_f(bottom+top)*.5)
                if not bottom<y<top:continue
                xs=[]
                for i,(x0,y0) in enumerate(points):
                    x1,y1=points[(i+1)%len(points)]
                    if min(y0,y1)<y<max(y0,y1):
                        xs.append(_f(_f(_f(_f(x1-x0)*_f(y-y0))/_f(y1-y0))+x0))
                xs.sort()
                for left,right in zip(xs[::2],xs[1::2]):
                    candidate=[_f(_f(left+right)*.5),y]
                    if left<candidate[0]<right and _polygon_contains(points,candidate):
                        candidates.append((right-left,candidate))
            if not candidates:raise RegionUnavailable('source_polygon_interior_navigation_point_unavailable')
            point=max(candidates,key=lambda c:c[0])[1];strategy='source_Polygon_Contains_verified_interior_interval'
    else:raise RegionUnavailable('region_shape_route_unsupported:'+str(name))
    return dict(world_position=point,native_center=center,navigation_strategy=strategy,
        geometry_definition_only=True,objective_arrival_confirmed=False)


def region_definition(normalized):
    """Definitions only. Exact case-sensitive region keys; no live facts."""
    result=dict(ready=False,missing=[],definition_only=True,source_refs=[])
    try:
        n=normalized
        if n.get('ready') is not True or n.get('status')!='supported' or n.get('operation')!='objective.move':
            raise RegionUnavailable('current_complete_typed_region_operands_required')
        if n.get('data_type') not in ('MM.QDBL.CodeGenerated.ArriveAtRegionQuestObjectiveData',
                'MM.QDBL.CodeGenerated.MoveToPermanentChannelQuestObjectiveData'):
            raise RegionUnavailable('typed_region_objective_family_unsupported')
        if n.get('source_version')!=rules()['source_version']:
            # Producers may attach additional source-version metadata.
            if any((n.get('source_version') or {}).get(k)!=v for k,v in rules()['source_version'].items()):
                raise RegionUnavailable('region_source_version_mismatch')
        if any(n.get('scope',{}).get(k) is None for k in SCOPE):raise RegionUnavailable('current_region_occurrence_required')
        channel=n.get('operands',{}).get('channel',{});region=n.get('operands',{}).get('region',{})
        if channel.get('declared_type')!='Shared.Symbols.ChannelSheetId' or channel.get('table_domain')!='ChannelSheet' or type(channel.get('id')) is not int:
            raise RegionUnavailable('exact_ChannelSheetId_operand_required')
        if region.get('declared_type')!='System.String' or not isinstance(region.get('value'),str) or not region['value']:
            raise RegionUnavailable('exact_nonempty_RegionName_required_no_default_region_inference')
        from .arbeit_movement import movement_definition
        domains=movement_definition()['descriptor_domains']
        if not domains.get('channel_game_space_join',{}).get('verified') or any(domains.get(k)!=v for k,v in rules()['source_version'].items()):
            raise RegionUnavailable('Channel_GameSpace_source_join_required')
        matches=[r for r in domains['domains']['ChannelName']['rows'] if r['id']==channel['id']&0xffffffff]
        if len(matches)!=1:raise RegionUnavailable('ChannelSheetId_definition_missing_or_ambiguous')
        row=matches[0];space=row['game_space'];world=space['world_data_key']
        definitions=rules()['region_definitions'].get(world)
        if definitions is None:raise RegionUnavailable('region_asset_not_bundled:'+world)
        definition=definitions.get(region['value'])
        if not isinstance(definition,dict) or definition.get('$type')!='Region':
            raise RegionUnavailable('exact_region_definition_missing:'+world+'/'+region['value'])
        shape=definition.get('shape') or {}
        route=region_route_point(shape);point=route['world_position']
        asset=next(r for r in rules()['asset_sources'] if r['asset']=='WorldData/'+world+'/Regions.sjon.bin')
        result.update(ready=True,channel_id=row['id'],region_name=region['value'],world_data_key=world,
            geo_data_key=space['geo_data_key'],world_position=point,shape=deepcopy(shape),region_geometry=route,
            constraints=deepcopy(n.get('constraints',{})),scope=deepcopy(n['scope']),request_key=_request_key(n),
            region_names=[space['name']],source_refs=[deepcopy(asset),deepcopy(domains['channel_game_space_join'])],
            objective_arrival_confirmed=False,arrival_policy='same_occurrence_native_QuestObjective_IsAchieved_only')
    except (KeyError,TypeError,ValueError,StopIteration) as exc:result['missing']=[str(exc)]
    return result


def _fields(session,pointer,name):
    if not pointer:raise RegionUnavailable('current_projection_object_missing:'+name)
    schema=next(c for c in rules()['classes'] if c['name']==name)
    klass=session.reader.u64(pointer)
    # Other bounded helpers may have validated a smaller subset of this same
    # class. A subset cache entry is not a proof of these newly required fields.
    cache=getattr(session,'_validated',{})
    key=(klass,schema['type_index'])
    if key in cache and not {f['name'] for f in schema['fields']}<=cache[key].keys():cache.pop(key)
    return session.fields(klass,schema)


def _read(session,address,fmt):
    return struct.unpack('<'+fmt,session.watch(address,struct.calcsize('<'+fmt)))


def _ref(session,pointer,fields,name):return _read(session,pointer+fields[name],'Q')[0]


def project_current_world_position(session,current_map_snapshot,world_position,*,context=None,expected_world=None,expected_geo=None):
    """Source-pinned world Vec2 -> CURRENT local map point, one height sample.

    Caller must retain the session's guards and reject changes before emitting
    the outer observation. This helper does not authorize input or open a process.
    """
    r=session.reader;s=current_map_snapshot
    if not s.get('map_ready') or s.get('map_transition') or (s.get('pose') or {}).get('map_type')==1:
        raise RegionUnavailable('current_settled_local_map_projection_required')
    if expected_world is not None and s.get('world_data_key')!=expected_world:raise RegionUnavailable('current_projection_world_mismatch')
    if expected_geo is not None and s.get('geo_data_key')!=expected_geo:raise RegionUnavailable('current_projection_geo_mismatch')
    if any(session.plan.get(k)!=v for k,v in rules()['source_version'].items()):raise RegionUnavailable('current_projection_client_version_mismatch')
    for probe in rules()['native_probes']:
        raw=bytes.fromhex(probe['raw_hex'])
        if session.watch(session.base+probe['rva'],len(raw))!=raw:raise RegionUnavailable('current_projection_native_source_changed:'+probe['owner']+'.'+probe['name'])
    from .vendor.map_character_context import active_context
    context=context if context is not None else active_context(r,session.base,session.character_plan)
    entity=context['chain'][3];ef=_fields(session,entity,'SingularEntity')
    parent=r.u64(r.u64(entity)+0x58);ff=session.fields(parent,session.schema('EntityBase'))
    flags=_ref(session,entity,ff,'ComponentUpdateStates');count,_=session.array(flags,name='Byte[]',stride=1,limit=30000)
    index=rules()['map_convert_presence_flag']
    if count<=index or not session.watch(flags+32+index,1)[0]:raise RegionUnavailable('current_MapConvertUIComponent_not_present')
    component=_ref(session,entity,ef,'MapConvertUIComponent');cf=_fields(session,component,'MapConvertUIComponent')
    calculator=_ref(session,component,cf,'calc');kf=_fields(session,calculator,'MapPositionCalculator')
    converter=_ref(session,calculator,kf,'mapCoordinationConverter');vf=_fields(session,converter,'MapCoordinatesConverter')
    height=_ref(session,calculator,kf,'heightMapSource');hf=_fields(session,height,'HeightMapSource')
    if height!=_ref(session,component,cf,'heightMapSource') or height!=_ref(session,converter,vf,'heightMapSource'):
        raise RegionUnavailable('current_projection_height_source_changed')
    if _read(session,height+hf['isDisposed'],'B')[0]!=0:raise RegionUnavailable('current_height_source_disposed')
    geo=session.map_key(height+hf['geoDataKey'],'GeoDataKey')
    if geo!=s.get('geo_data_key') or expected_geo is not None and geo!=expected_geo:raise RegionUnavailable('current_height_geo_source_mismatch')
    bounds=_read(session,height+hf['bounds'],'4f');resolution=_read(session,height+hf['resolution'],'2i')
    x,z=_pair(world_position)
    if not all(math.isfinite(v) for v in bounds) or min(bounds[2:])<=0 or min(resolution)<=0:
        raise RegionUnavailable('current_height_bounds_or_resolution_invalid')
    if not (bounds[0]<=x<=bounds[0]+bounds[2] and bounds[1]<=z<=bounds[1]+bounds[3]):
        raise RegionUnavailable('region_center_outside_current_height_bounds')
    indices=[max(0,min(resolution[i]-1,math.trunc(_f(_f(_f(_f(p-bounds[i])/bounds[i+2])*resolution[i])-.5))))
             for i,p in enumerate((x,z))]
    data=_ref(session,height,hf,'heightMapData');df=_fields(session,data,'ManagedHeightMapData')
    if _read(session,data+df['heightMapResolution'],'2i')!=resolution or _read(session,data+df['bounds'],'4f')!=bounds:
        raise RegionUnavailable('current_height_source_data_bounds_mismatch')
    quantized=_read(session,data+df['useQuantizeHeight'],'B')[0]
    if quantized not in (0,1):raise RegionUnavailable('current_height_storage_flag_invalid')
    array=_ref(session,data,df,'quantizedHeights' if quantized else 'rawHeights')
    length,stride=session.array(array,name='UInt16[]' if quantized else 'Single[]',stride=2 if quantized else 4,limit=16*1024*1024)
    session.watch(array+24,8)
    sample_index=indices[0]+indices[1]*resolution[0]
    if length!=resolution[0]*resolution[1] or sample_index>=length:raise RegionUnavailable('current_height_array_shape_invalid')
    sample=_read(session,array+32+stride*sample_index,'H' if quantized else 'f')[0]
    if quantized:
        low=_read(session,data+df['minHeight'],'f')[0];high=_read(session,data+df['maxHeight'],'f')[0]
        y=_f(_f(_f(sample/65535.)*_f(high-low))+low)
    else:y=_f(sample)
    matrix=_read(session,converter+vf['worldToMap'],'16f');padding=_read(session,converter+vf['worldPosPadding'],'2f')
    scale=_read(session,converter+vf['camToSourceTexScale'],'i')[0]
    if not all(math.isfinite(v) for v in matrix+padding) or scale<=0:raise RegionUnavailable('current_map_matrix_or_scale_invalid')
    # Native Vector4 row-vector multiplication; no guessed static camera and no
    # perspective divide. The native method explicitly selects output X/Y.
    projected=[]
    for col in (0,1):
        value=_f(_f(_f(_f(x*matrix[col])+_f(y*matrix[4+col]))+_f(z*matrix[8+col]))+matrix[12+col])
        projected.append(_f(_f(value+padding[col])*scale))
    return dict(confirmed=True,source=SOURCE,source_ref=SOURCE_REF,source_hash=SOURCE_HASH,
        source_version=deepcopy(rules()['source_version']),coordinate_space='current-map',map_position=projected,
        world_position=[x,z],terrain_height=y,height_sample_index=sample_index,height_sample_storage='UInt16' if quantized else 'Single',
        world_data_key=s['world_data_key'],geo_data_key=geo,viewing_channel=s.get('viewing_channel'),
        observed_at=s.get('captured_at'),process_started_at=s.get('process_started_at'),
        character_id=(s.get('identity') or {}).get('character_id'),session_id=s.get('session_id'),
        source_roots={'component':hex(component),'calculator':hex(calculator),'converter':hex(converter),'height':hex(height)},
        native_calls=0,static_world_projection=False,objective_arrival_confirmed=False)


def read_region_targets(session,current_map_snapshot,normalized_operands_list,*,context=None):
    """Observe only requested source-backed region centers through current roots."""
    if not isinstance(normalized_operands_list,list) or len(normalized_operands_list)>128:
        raise RegionUnavailable('region_request_list_outside_bound')
    rows=[]
    for normalized in normalized_operands_list:
        definition=region_definition(normalized)
        row=dict(definition,confirmed=False,source=SOURCE,source_ref=SOURCE_REF,source_hash=SOURCE_HASH,
            observed_at=current_map_snapshot.get('captured_at'))
        if definition['ready']:
            try:
                p=project_current_world_position(session,current_map_snapshot,definition['world_position'],context=context,
                    expected_world=definition['world_data_key'],expected_geo=definition['geo_data_key'])
                row.update(p,scope=deepcopy(normalized['scope']),request_key=definition['request_key'],definition_only=False,
                    constraints=deepcopy(normalized.get('constraints',{})),missing=[])
            except (ValueError,RuntimeError,KeyError,struct.error) as exc:row.update(ready=False,missing=[str(exc)])
        rows.append(row)
    return rows


def bind_typed_region_goal(normalized,current_map,*,expected_objective=None,now=None):
    """Bind direct static region definition to a fresh current projected point."""
    d=region_definition(normalized);result=dict(d,ready=False,goal=None,objective_arrival_confirmed=False)
    if not d['ready']:return result
    try:
        at=now or datetime.now(timezone.utc);n=normalized;s=current_map
        if expected_objective is not None and any(n['scope'].get(k)!=expected_objective[k] for k in SCOPE if k in expected_objective):
            raise RegionUnavailable('current_region_expected_occurrence_mismatch')
        if s.get('certainty')!='confirmed' or not s.get('observation_source'):raise RegionUnavailable('current_region_map_source_required')
        for stamp in (n.get('observed_at'),s.get('captured_at')):
            if not -.5<=(at-_time(stamp)).total_seconds()<=5:raise RegionUnavailable('current_region_observation_stale')
        if (n.get('scope',{}).get('process_started_at'),n.get('session_id'),n.get('character_id'))!=(s.get('process_started_at'),s.get('session_id'),(s.get('identity') or {}).get('character_id')):
            raise RegionUnavailable('current_region_map_owner_mismatch')
        rows=[r for r in s.get('typed_region_targets',[]) if r.get('request_key')==d['request_key']]
        if len(rows)!=1:raise RegionUnavailable('current_region_projection_missing_or_ambiguous')
        p=rows[0]
        if (p.get('confirmed') is not True or p.get('ready') is not True or p.get('source')!=SOURCE
            or p.get('source_hash')!=SOURCE_HASH or p.get('scope')!=n['scope'] or p.get('world_position')!=d['world_position']
            or p.get('world_data_key')!=d['world_data_key'] or p.get('geo_data_key')!=d['geo_data_key']
            or p.get('observed_at')!=s.get('captured_at') or s.get('world_data_key')!=d['world_data_key'] or s.get('geo_data_key')!=d['geo_data_key']
            or (p.get('process_started_at'),p.get('character_id'))!=(s.get('process_started_at'),n.get('character_id'))):
            raise RegionUnavailable('current_region_projection_identity_or_source_unverified')
        goal=dict(kind='map_position',world_data_key=d['world_data_key'],map_position=_pair(p['map_position']),
            coordinate_space='current-map',region_names=d['region_names'],title=d['region_name'],
            channel_id=d['channel_id'],typed_region_scope=deepcopy(n['scope']),request_key=d['request_key'],
            source_refs=d['source_refs']+[{'path':SOURCE_REF,'sha256':SOURCE_HASH}],
            objective_arrival_confirmed=False,arrival_policy=d['arrival_policy'])
        return dict(result,ready=True,missing=[],goal=goal,projection=deepcopy(p))
    except (ValueError,TypeError,KeyError,AttributeError) as exc:return dict(result,missing=[str(exc)])


def plan_typed_region_movement(normalized,current_map,receipt=None,*,now=None):
    """Navigate source world UI, then direct point; never complete the objective.

    Separate view-navigation receipts from point-navigation receipts so an
    observed map transition can change the coordinate goal without losing an
    unresolved input. Only a fresh settled target-world frame permits handoff.
    """
    from .arbeit_movement import plan_movement
    d=region_definition(normalized);s=current_map
    def wait(reason):return dict(status='waiting',action=None,wait_reason=reason,evidence={'region_binding':d},next_receipt=deepcopy(receipt),sent_to_game=False,game_tested=False)
    if not d['ready']:return wait('typed_region_definition_unavailable:'+','.join(d['missing']))
    key=d['request_key']
    if receipt and receipt.get('region_request_key')!=key:return wait('pending_region_occurrence_or_operands_changed')
    owner=[s.get('pid'),s.get('process_started_at'),(s.get('identity') or {}).get('character_id'),s.get('session_id')]
    if (normalized.get('scope',{}).get('process_started_at'),normalized.get('session_id'),normalized.get('character_id'))!=(owner[1],owner[3],owner[2]):return wait('current_region_map_owner_mismatch')
    if receipt and receipt.get('owner')!=owner:return wait('pending_region_map_owner_changed')
    local=(s.get('map_visible') and s.get('map_ready') and (s.get('pose') or {}).get('map_type')!=1 and s.get('world_data_key')==d['world_data_key'])
    if receipt and receipt.get('region_stage')=='point' and receipt.get('phase')=='move_sent':
        # A dispatched plain move closes the map. Its arrival is observed from
        # current avatar/channel data; do not require another map/projection or
        # invent a new goal. The original planner verifies its stored goal key.
        goal=deepcopy(receipt.get('region_goal'))
        if (not isinstance(goal,dict) or goal.get('kind')!='map_position'
                or goal.get('request_key')!=key or goal.get('typed_region_scope')!=normalized['scope']
                or goal.get('world_data_key')!=d['world_data_key'] or goal.get('channel_id')!=d['channel_id']
                or (receipt.get('target') or {}).get('target_channel')!=d['channel_id']):
            return wait('pending_region_original_point_goal_required')
        answer=plan_movement(s,goal,receipt,now=now);stage='point'
    elif not local:
        if receipt and receipt.get('region_stage')=='point':return wait('pending_region_point_navigation_unresolved')
        # This internal route key identifies a VIEW request, never a real marker
        # or a coordinate. plan_movement reaches only map.open/world/region here.
        goal=dict(kind='marker',marker_id='region-view:'+key,world_data_key=d['world_data_key'],region_names=d['region_names'])
        answer=plan_movement(s,goal,receipt,now=now);stage='view'
    else:
        if receipt and receipt.get('region_stage')=='view':
            if receipt.get('phase') not in ('open_sent','world_sent','region_sent','pan_sent','zoom_sent') or _time(s['captured_at'])<=_time(receipt['captured_at']):
                return wait('region_map_transition_fresh_successor_required')
            prior=None
        else:prior=receipt
        binding=bind_typed_region_goal(normalized,s,now=now)
        if not binding['ready']:return wait('typed_region_projection_unavailable:'+','.join(binding['missing']))
        selected=s.get('selected') or {};detail=selected.get('detail') or {}
        if selected.get('long_press') and detail.get('target_channel') is not None and detail['target_channel']!=d['channel_id']:
            return wait('selected_region_plain_move_channel_mismatch')
        goal=binding['goal']
        answer=plan_movement(s,goal,prior,now=now);stage='point'
        answer.setdefault('evidence',{})['region_binding']=binding
    if answer.get('next_receipt') is not None:
        answer['next_receipt'].update(region_request_key=key,region_stage=stage)
        if stage=='point':answer['next_receipt']['region_goal']=deepcopy(goal)
    answer.setdefault('evidence',{}).update(objective_arrival_confirmed=False,
        arrival_policy='same_occurrence_native_QuestObjective_IsAchieved_only',channel_id=d['channel_id'],region_name=d['region_name'])
    return answer
