"""Actual read-only map adapter. Import/construction never opens a process.

The application must explicitly enable a read. This module does not elevate,
install helpers, change process rights, click, execute client code, or scan the
heap. Reused typed MapMemorySession follows the CURRENT character root and
checks GameAssembly/metadata hashes and the named fields before publishing.

MapObservationProvider(pid, session_id=...).read(allow_process_read=True,
    layout=..., viewport=..., window_owner=...,
    expected_character_id=..., expected_process_started_at=...)
returns {ok, snapshot?, reason?, read_attempted, memory_writes:0, game_inputs:0}.
The layout is current physical-client viewport/safe-area/canvas state supplied
by the host: pid, viewport [w,h], safe_rect [l,t,w,h], canvas_scale,
orientation, observed_at, source, certainty='confirmed'. On a ready map, fresh
viewport/window_owner evidence can instead bind the guarded current shared
MMML globals. Missing both paths returns planner_ready=False; no pixel defaults
are invented. Map data are not NPC-dialog/quest-offer observations.
"""
from copy import deepcopy
from datetime import datetime, timezone
import hashlib
import json
import math
import threading
from types import SimpleNamespace

from .arbeit_movement import movement_definition
from .vendor.map_memory import MapMemorySession, MapReadPending
from .vendor.map_readonly_reader import QuestReadCancelled
from .vendor.ui_layout import Rect, RectTransform, child_rect
from .vendor.map_screen_padding import calculate_screen_safe_area


class MapLayoutUnavailable(ValueError):
    pass


def _now():
    return datetime.now(timezone.utc)


def _number(value):
    if type(value) not in (int,float) or not math.isfinite(value):
        raise MapLayoutUnavailable('nonfinite_layout_value')
    return float(value)


def _pair(value):
    if type(value) not in (list,tuple) or len(value)!=2:
        raise MapLayoutUnavailable('layout_pair_missing')
    return [_number(v) for v in value]


def current_map_layout(snapshot, *, pid, viewport, window_owner, now=None):
    """Bind guarded MMML globals to a freshly owned physical client viewport.

    Window evidence: {pid, hwnd, observed_at, source, certainty:'confirmed',
    process_started_at?}. It comes from the capture/window port, not this
    memory reader. Current MMML AnchorMin/Max are the safe-area authority;
    original desktop screen-padding rules are retained as a comparison.
    """
    now=now or _now()
    if not isinstance(window_owner,dict) or window_owner.get('pid')!=pid or not window_owner.get('hwnd') or not window_owner.get('source') or window_owner.get('certainty')!='confirmed':
        raise MapLayoutUnavailable('current_window_owner_missing')
    if not snapshot.get('map_ready') or not snapshot.get('map_visible'):
        raise MapLayoutUnavailable('current_map_not_ready_for_layout')
    for stamp in (window_owner.get('observed_at'),snapshot.get('captured_at')):
        if not isinstance(stamp,str):raise MapLayoutUnavailable('current_layout_time_missing')
        value=datetime.fromisoformat(stamp.replace('Z','+00:00'))
        if value.tzinfo is None or not -.5<=(now-value).total_seconds()<=5:
            raise MapLayoutUnavailable('current_map_or_window_layout_stale')
    if window_owner.get('process_started_at') is not None and window_owner['process_started_at']!=snapshot.get('process_started_at'):
        raise MapLayoutUnavailable('current_layout_process_restarted')
    width,height=_pair(viewport)
    if min(width,height)<=0:raise MapLayoutUnavailable('current_viewport_invalid')
    raw=snapshot.get('current_map_layout')
    if not isinstance(raw,dict) or not raw.get('field_guarded') or raw.get('certainty')!='confirmed':
        raise MapLayoutUnavailable('current_mmml_layout_missing:'+str(snapshot.get('current_map_layout_missing','no current shared service')))
    scale=_number(raw.get('canvas_scale'))
    screen=_pair(raw.get('screen_ui'))
    if scale<=0 or min(screen)<=0 or any(abs(actual-ui*scale)>max(1.,actual*1e-4) for actual,ui in zip((width,height),screen)):
        raise MapLayoutUnavailable('current_mmml_screen_physical_viewport_mismatch')
    pose=_pair(snapshot['pose']['layout_size_ui'])
    if any(abs(a-b)>max(.5,a*1e-4) for a,b in zip(screen,pose)):
        raise MapLayoutUnavailable('current_map_image_mmml_layout_mismatch')
    rule=movement_definition()['current_map_layout']
    orientation=next((key.lower() for key,value in rule['orientation'].items() if value==raw.get('orientation_raw') and key in ('Portrait','Landscape')),None)
    if orientation is None or ((width>height)!=(orientation=='landscape')):
        raise MapLayoutUnavailable('current_mmml_orientation_mismatch')
    low,high=_pair(raw.get('anchor_min')),_pair(raw.get('anchor_max'))
    if any(not 0<=a<b<=1 for a,b in zip(low,high)):
        raise MapLayoutUnavailable('current_mmml_safe_area_anchors_invalid')
    safe=[low[0]*width,(1-high[1])*height,(high[0]-low[0])*width,(high[1]-low[1])*height]
    expected=calculate_screen_safe_area(SimpleNamespace(root_rect=Rect(0,0,width,height),orientation=orientation,scale=scale),rule['screen_padding'])
    expected_rect=[expected.left,expected.top,expected.width,expected.height]
    return dict(pid=pid,hwnd=window_owner['hwnd'],source=raw['source'],certainty='confirmed',
        observed_at=snapshot['captured_at'],viewport=[width,height],safe_rect=safe,canvas_scale=scale,orientation=orientation,
        window_source=window_owner['source'],window_observed_at=window_owner['observed_at'],
        safe_area_source='current MMMLService.global_SafeArea AnchorMin/AnchorMax',
        screen_padding_source_refs=rule['screen_padding']['source_refs'],
        historical_screen_padding_matches=all(abs(a-b)<=1 for a,b in zip(safe,expected_rect)),
        source_refs=raw.get('source_refs',[]),live_tested_in_dev2=False)


def prepared_map_controls(layout, *, pid, now=None):
    """Apply the same mini27 prefab anchors to an explicitly current layout."""
    if not isinstance(layout,dict) or layout.get('pid')!=pid or not layout.get('source') or layout.get('certainty')!='confirmed':
        raise MapLayoutUnavailable('current_layout_source_or_owner_missing')
    observed=datetime.fromisoformat(layout['observed_at'].replace('Z','+00:00'))
    if observed.tzinfo is None:
        raise MapLayoutUnavailable('layout_timezone_missing')
    age=((now or _now())-observed).total_seconds()
    if age < -.5 or age > 5:
        raise MapLayoutUnavailable('current_layout_stale')
    width,height=_pair(layout['viewport'])
    if min(width,height)<=0:
        raise MapLayoutUnavailable('current_viewport_invalid')
    safe=Rect(*layout['safe_rect'])
    scale=_number(layout['canvas_scale'])
    orientation=layout.get('orientation')
    if orientation not in ('landscape','portrait') or scale<=0 or not Rect(0,0,width,height).contains(safe):
        raise MapLayoutUnavailable('current_safe_area_or_canvas_missing')
    definition=movement_definition()
    x,y,w,h=definition['hud_rules']['values']['coordinates.observation.hud.magnets'][orientation]['map']
    minimap=child_rect(safe,RectTransform((1,1),(1,1),(1,1),(x,y),(w,h)),scale)
    targets={'map.open':minimap.inset(min(w,h)*scale*.25)}
    for name,row in definition['map_rules']['controls'].items():
        ox,oy,rw,rh=row
        cx=safe.right+ox*scale if name in ('close','my_location') else safe.left+ox*scale
        cy=safe.bottom-oy*scale if name=='my_location' else safe.top+oy*scale
        targets['map.'+name]=Rect(cx-rw*scale/2,cy-rh*scale/2,rw*scale,rh*scale)
    if any(not safe.contains(rect) for rect in targets.values()):
        raise MapLayoutUnavailable('prepared_map_controls_clipped')
    return {'controls':{role:list(rect.center) for role,rect in targets.items()},
            'rectangles':{role:[rect.left,rect.top,rect.width,rect.height] for role,rect in targets.items()},
            'source':definition['map_rules']['provenance'], 'layout_source':layout['source'],
            'source_live_verified':False, 'coordinate_space':'physical-client-pixels'}


class MapObservationProvider:
    def __init__(self,pid,*,session_id,cancel=None,session_factory=None,reader_factory=None):
        if type(pid) is not int or pid<=0 or not isinstance(session_id,str) or not session_id:
            raise ValueError('explicit_pid_and_session_required')
        self.pid=pid
        self.session_id=session_id
        self.cancel=cancel or threading.Event()
        self.session_factory=session_factory or MapMemorySession
        self.reader_factory=reader_factory

    def read(self,*,allow_process_read=False,layout=None,include_map=True,
             expected_character_id=None,expected_process_started_at=None,viewport=None,window_owner=None,region_requests=None):
        result={'ok':False,'read_attempted':False,'memory_writes':0,'game_inputs':0,
                'client_functions_called':0,'privilege_adjustments':0,'heap_scans':0,
                'source':'version-pinned-current-root-map-memory','observed_at':_now().isoformat(),
                'live_tested_in_dev2':False}
        if allow_process_read is not True:
            return dict(result,status='blocked',reason='process_read_not_explicitly_enabled')
        if self.cancel.is_set():
            return dict(result,status='cancelled',reason='cancelled_before_process_open')
        result['read_attempted']=True
        try:
            options={'cancel':self.cancel}
            if self.reader_factory is not None:
                options['reader_factory']=self.reader_factory
            with self.session_factory(self.pid,**options) as session:
                request_options={'region_requests':region_requests} if region_requests else {}
                snapshot=session.read(include_map=include_map,**request_options)
                if snapshot.get('pid')!=self.pid:
                    raise MapLayoutUnavailable('map_reader_pid_changed')
                if expected_character_id is not None and str(expected_character_id)!=snapshot['identity']['character_id']:
                    raise MapLayoutUnavailable('map_reader_character_changed')
                if expected_process_started_at is not None and expected_process_started_at!=snapshot['process_started_at']:
                    raise MapLayoutUnavailable('map_reader_process_restarted')
                result['reader_bytes_read']=getattr(session.reader,'bytes_read',None)
                result['reader_failures']=getattr(session.reader,'failures',None)
                result['source_version']={key:session.plan[key] for key in ('game_assembly_sha256','metadata_sha256')}
            if self.cancel.is_set():
                return dict(result,status='cancelled',reason='cancelled_after_read')
            snapshot=deepcopy(snapshot)
            snapshot.update(observation_source=result['source'],certainty='confirmed',session_id=self.session_id,
                            source_version=deepcopy(result['source_version']))
            if snapshot.get('current_autoplay'):
                snapshot['current_autoplay'].update(observed_at=snapshot['captured_at'],session_id=self.session_id,
                    character_id=snapshot['identity']['character_id'],process_started_at=snapshot['process_started_at'])
            result.update(ok=True,status='observed',snapshot=snapshot,planner_ready=False)
            if layout is None and viewport is not None and window_owner is not None:
                try:
                    layout=current_map_layout(snapshot,pid=self.pid,viewport=viewport,window_owner=window_owner)
                    snapshot['layout_evidence']=deepcopy(layout)
                except (MapLayoutUnavailable,KeyError,TypeError,ValueError) as exc:
                    result['planner_wait_reason']='current_map_layout_unavailable:'+str(exc)
            if layout is None:
                result.setdefault('planner_wait_reason','current_window_canvas_layout_missing')
            else:
                try:
                    geometry=prepared_map_controls(layout,pid=self.pid)
                    snapshot.update(viewport=list(layout['viewport']),safe_rect=list(layout['safe_rect']),
                                    controls=geometry['controls'],control_evidence=geometry,
                                    layout_observed_at=layout['observed_at'],layout_source=layout['source'])
                    result['planner_ready']=True
                except (MapLayoutUnavailable,KeyError,TypeError,ValueError) as exc:
                    result['planner_wait_reason']='current_layout_unavailable:'+str(exc)
            result['snapshot_sha256']=hashlib.sha256(json.dumps(snapshot,ensure_ascii=False,sort_keys=True,allow_nan=False).encode()).hexdigest()
            return result
        except QuestReadCancelled as exc:
            return dict(result,status='cancelled',reason=str(exc))
        except MapReadPending as exc:
            return dict(result,status='waiting',reason='current_map_changed_or_not_ready:'+str(exc))
        except (OSError,RuntimeError,ValueError,KeyError,TypeError) as exc:
            return dict(result,status='blocked',reason='map_read_unavailable:'+str(exc),error_type=type(exc).__name__)
