"""Bounded read-only Win32 transport adapted from a recorded mini27 snapshot.

Only QUERY_INFORMATION|VM_READ (0x410); no heap enumeration, client code call,
write API, injection, privilege change, input, or automatic escalation exists.
Importing this module performs no process access.
"""
from __future__ import annotations
import ctypes as c
from ctypes import wintypes as w
from datetime import datetime, timezone
import hashlib
from pathlib import Path
import struct
import threading

_HASH_CACHE = {}
_HASH_LOCK = threading.Lock()



def file_sha256(path):
    path = Path(path)
    info = path.stat()
    key = (str(path.resolve()), info.st_size, info.st_mtime_ns)
    with _HASH_LOCK:
        if key in _HASH_CACHE:
            return _HASH_CACHE[key]
    with path.open('rb') as stream:
        digest = hashlib.file_digest(stream, 'sha256').hexdigest()
    after = path.stat()
    if (info.st_size,info.st_mtime_ns)!=(after.st_size,after.st_mtime_ns):
        raise RuntimeError('읽기 중 클라이언트 파일이 변경되었습니다. 다시 불러와 주세요.')
    with _HASH_LOCK:
        if len(_HASH_CACHE) >= 16:
            _HASH_CACHE.pop(next(iter(_HASH_CACHE)))
        _HASH_CACHE[key] = digest
    return digest

class QuestReadCancelled(RuntimeError):
    pass

class Reader:

    def __init__(self, pid, cancel=None):
        self.pid, self.bytes_read, self.failures = pid, 0, 0
        self.cancel = cancel or threading.Event()
        self.kernel = c.WinDLL('kernel32', use_last_error=True)
        self.kernel.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]
        self.kernel.OpenProcess.restype = w.HANDLE
        self.kernel.CloseHandle.argtypes = [w.HANDLE]
        self.kernel.CloseHandle.restype = w.BOOL
        self.kernel.ReadProcessMemory.argtypes = [w.HANDLE, c.c_void_p, c.c_void_p, c.c_size_t, c.POINTER(c.c_size_t)]
        self.kernel.ReadProcessMemory.restype = w.BOOL
        self.kernel.QueryFullProcessImageNameW.argtypes = [w.HANDLE, w.DWORD, w.LPWSTR, c.POINTER(w.DWORD)]
        self.kernel.QueryFullProcessImageNameW.restype = w.BOOL
        self.kernel.GetProcessTimes.argtypes = [w.HANDLE,c.POINTER(w.FILETIME),c.POINTER(w.FILETIME),c.POINTER(w.FILETIME),c.POINTER(w.FILETIME)]
        self.kernel.GetProcessTimes.restype = w.BOOL
        self.handle = self.kernel.OpenProcess(0x410, False, pid)
        if not self.handle:
            raise OSError(c.get_last_error(), '현재 권한으로 게임 자료를 읽을 수 없습니다. 권한 변경/자동 재시도는 하지 않습니다.')
        try:
            self._confirm_process()
        except BaseException:
            self.close()
            raise
        self.layout_cache = {}

    def _confirm_process(self):
        size, path = w.DWORD(32768), c.create_unicode_buffer(32768)
        if not self.kernel.QueryFullProcessImageNameW(self.handle, 0, path, c.byref(size)):
            raise OSError(c.get_last_error(), 'Cannot confirm selected process image.')
        if Path(path.value).name.casefold() != 'mabinogimobile.exe':
            raise RuntimeError('Selected process is not the requested game.')
        self.image = path.value
        created,ended,kernel_time,user_time = w.FILETIME(),w.FILETIME(),w.FILETIME(),w.FILETIME()
        if not self.kernel.GetProcessTimes(self.handle,c.byref(created),c.byref(ended),c.byref(kernel_time),c.byref(user_time)):
            raise OSError(c.get_last_error(),'Cannot confirm game process creation time.')
        ticks = (created.dwHighDateTime << 32) | created.dwLowDateTime
        self.started_at = datetime.fromtimestamp((ticks-116444736000000000)/10000000,timezone.utc)

    def close(self):
        if self.handle:
            self.kernel.CloseHandle(self.handle)
            self.handle = None

    def read(self, address, size):
        if self.cancel.is_set():
            raise QuestReadCancelled('퀘스트 읽기를 취소했습니다. 저장 목록은 유지됩니다.')
        if not 0x10000 <= address < 0x800000000000 or not 0 < size <= 4 * 1024 * 1024:
            raise ValueError('Invalid bounded quest memory range.')
        buffer, received = c.create_string_buffer(size), c.c_size_t()
        good = self.kernel.ReadProcessMemory(self.handle, c.c_void_p(address), buffer, size, c.byref(received))
        self.bytes_read += received.value
        if not good or received.value != size:
            self.failures += 1
            raise OSError(c.get_last_error(), 'Requested readable quest range unavailable.')
        return buffer.raw

    def unpack(self, address, pattern):
        return struct.unpack(pattern, self.read(address, struct.calcsize(pattern)))[0]

    def u64(self, address): return self.unpack(address, '<Q')

    def u32(self, address): return self.unpack(address, '<I')

    def i32(self, address): return self.unpack(address, '<i')

    def u16(self, address): return self.unpack(address, '<H')

    def ascii(self, address, allow_empty=False):
        value = self.read(address, 192).split(b'\0', 1)[0]
        if (not value and not allow_empty) or len(value) > 180:
            raise ValueError('Invalid bounded class/field identifier.')
        return value.decode('ascii')

    def class_name(self, address):
        return self.ascii(self.u64(address + 0x10)), self.ascii(self.u64(address + 0x18),True)

    def string(self, pointer):
        if not pointer:
            return None
        if self.class_name(self.u64(pointer)) != ('String','System'):
            raise ValueError('Quest string object type not verified.')
        length = self.i32(pointer+16)
        if not 0 <= length <= 8192:
            raise ValueError('Quest text length out of bounds.')
        return self.read(pointer+20,length*2).decode('utf-16-le') if length else ''

    def layout(self, klass, expected=None):
        if klass in self.layout_cache:
            result = self.layout_cache[klass]
            if expected and [f['name'] for f in result['fields']] != expected:
                raise ValueError('Class fields differ from current file metadata.')
            return result
        name, namespace = self.class_name(klass)
        count = self.u16(klass + 0x120)
        if not 0 <= count <= 256 or (expected is not None and count != len(expected)):
            raise ValueError('Runtime class field count not verified.')
        pointer, fields = self.u64(klass + 0x80), []
        for index in range(count):
            info = self.read(pointer + 32 * index, 32)
            name_pointer, type_pointer, parent, offset, token = struct.unpack('<QQQiI', info)
            field_name = self.ascii(name_pointer)
            if not -1 <= offset <= 65536:
                raise ValueError('Runtime field offset not verified: ' + name)
            if parent != klass and self.class_name(parent) != (name,namespace):
                raise ValueError('Runtime field ownership not verified: ' + name)
            if expected is not None and field_name != expected[index]:
                raise ValueError('Runtime field identifier not in the current quest plan.')
            fields.append(dict(name=field_name, offset=offset,
                               type_code=(self.u32(type_pointer + 8) >> 16) & 255))
        result = dict(klass=hex(klass), name=name, namespace=namespace,
                      instance_size=self.u32(klass + 0xf8), element_size=self.u32(klass + 0x100),
                      parent_class=hex(self.u64(klass + 0x58)),
                      fields=fields)
        self.layout_cache[klass] = result
        return result

    def module_base(self):
        api = c.WinDLL('psapi', use_last_error=True)
        api.EnumProcessModulesEx.argtypes = [w.HANDLE, c.POINTER(w.HMODULE), w.DWORD, c.POINTER(w.DWORD), w.DWORD]
        api.EnumProcessModulesEx.restype = w.BOOL
        api.GetModuleFileNameExW.argtypes = [w.HANDLE, w.HMODULE, w.LPWSTR, w.DWORD]
        api.GetModuleFileNameExW.restype = w.DWORD
        modules, needed = (w.HMODULE * 4096)(), w.DWORD()
        if not api.EnumProcessModulesEx(self.handle, modules, c.sizeof(modules), c.byref(needed), 3):
            raise OSError(c.get_last_error(), 'Read-only game module query failed.')
        if needed.value > c.sizeof(modules):
            raise RuntimeError('Module query exceeded its bounded buffer.')
        for index in range(needed.value // c.sizeof(w.HMODULE)):
            path = c.create_unicode_buffer(32768)
            if api.GetModuleFileNameExW(self.handle, modules[index], path, len(path)) and Path(path.value).name.casefold() == 'gameassembly.dll':
                return int(modules[index]), path.value
        raise RuntimeError('GameAssembly module not found in the selected game.')

    def dictionary(self, pointer):
        klass = self.u64(pointer)
        shape = self.layout(klass)
        if shape['name'] == 'MMDictionary`2' and shape['namespace'] == 'Silvervine.ManualMemory':
            hierarchy, current = [shape], shape
            for _ in range(5):
                parent = int(current['parent_class'],16)
                if not parent:
                    break
                current = self.layout(parent)
                hierarchy.append(current)
                if current['name'] == 'Object' and current['namespace'] == 'System':
                    break
            common = next((r for r in hierarchy if r['name'] == 'CommonDictionary`2'
                           and r['namespace'] == 'Silvervine.ManualMemory.Internal'),None)
            if common is None:
                raise ValueError('Manual dictionary base not confirmed.')
            fields = {f['name']:f for f in common['fields']}
            if set(fields) != {'keys','values','hashTable','version','count'}:
                raise ValueError('Manual dictionary field names not verified.')
            count = self.i32(pointer + fields['count']['offset'])
            version = self.i32(pointer + fields['version']['offset'])
            if not 0 <= count <= 20000:
                raise ValueError('Manual quest dictionary count invalid.')
            result = dict(kind='manual_dense',pointer=hex(pointer),count=count,version=version,
                          layout=common,count_offset=fields['count']['offset'],
                          version_offset=fields['version']['offset'],arrays={})
            for role in ('keys','values'):
                array = self.u64(pointer + fields[role]['offset'])
                klass_array = self.u64(array)
                name_array, namespace_array = self.class_name(klass_array)
                info = dict(pointer=hex(array),name=name_array,namespace=namespace_array,
                            layout=self.layout(klass_array))
                if name_array.endswith('[]'):
                    length = self.u64(array+24)
                    stride = self.u32(klass_array + 0x100)
                    if not count <= length <= 30000 or not 1 <= stride <= 4096:
                        raise ValueError('Manual quest array bounds not verified.')
                    element_class = self.u64(klass_array + 0x40)
                    info.update(length=length,stride=stride,element_layout=self.layout(element_class))
                result['arrays'][role] = info
            return result
        if shape['name'] != 'Dictionary`2' or shape['namespace'] != 'System.Collections.Generic':
            raise ValueError('Quest collection is ' + shape['namespace'] + '.' + shape['name'])
        fields = {f['name'].lstrip('_'): f for f in shape['fields']}
        for name in ('count', 'freeCount', 'entries', 'version'):
            if name not in fields:
                raise ValueError('Dictionary field layout incomplete.')
        used = self.i32(pointer + fields['count']['offset'])
        free = self.i32(pointer + fields['freeCount']['offset'])
        version = self.i32(pointer + fields['version']['offset'])
        if not 0 <= free <= used <= 20000:
            raise ValueError('Quest dictionary count out of bounds.')
        entries = self.u64(pointer + fields['entries']['offset'])
        result = dict(pointer=hex(pointer), layout=shape, used=used, free=free, count=used-free, version=version,
                      entries=hex(entries), count_offset=fields['count']['offset'],
                      version_offset=fields['version']['offset'])
        if entries:
            array_class = self.u64(entries)
            element_class = self.u64(array_class + 0x40)
            entry_shape = self.layout(element_class)
            if entry_shape['name'] != 'Entry':
                raise ValueError('Quest dictionary entry element class not verified.')
            length, stride = self.u64(entries + 24), self.u32(array_class + 0x100)
            if not used <= length <= 30000 or not 8 <= stride <= 4096:
                raise ValueError('Quest entry array length/stride invalid.')
            result.update(length=length, stride=stride, entry_layout=entry_shape)
        elif used:
            raise ValueError('Nonempty quest dictionary has no entries.')
        return result
