"""Real guest window-management checks. All input targets this harness's private QEMU."""
import argparse
import pathlib
import re
import subprocess
import statistics
import sys
import time
from PIL import Image, ImageChops, ImageDraw

STATE = re.compile(r'^XFE_WINDOW_STATE\|(\d+)\|([^|]+)\|(-?\d+)\|(-?\d+)\|(\d+)\|(\d+)\|([01])\|([01])\|([01])$', re.M)
APPEARANCE = re.compile(r'^XFE_DESKTOP_APPEARANCE\|(Light|Dark)\|([0-9A-F]{6})\|Persistent\|([01])$', re.M)
CHROME = re.compile(r'^XFE_CHROME_FRAME\|(\d+)\|Min\|(\d+)\|Close\|(\d+)\|Title\|(\d+)\|Resize\|(\d+)$', re.M)

def parse_render_stats(log):
    """Return complete, validated native statistics; never accept a serial tail.

    A malformed newline-terminated record is a protocol failure, not a sample
    to silently skip. Only the final not-yet-newline-terminated fragment waits
    for a later read, even if some of its fields already look valid.
    """
    required = {'Frames', 'Partial', 'Pixels', 'Glyphs', 'Fallback', 'LastRenderTicks', 'Frequency',
                'MotionReuses', 'MotionPixels', 'ScanoutPixelsSaved'}
    result = []
    # Split only on the protocol terminator: splitlines() also treats control
    # characters inside a malformed field as line boundaries.
    for line_number, line in enumerate(log.split('\n')[:-1], 1):
        if line.endswith('\r'): line = line[:-1]
        if not line.startswith('XFE_RENDER_STATS'): continue
        def malformed(reason):
            raise ValueError(f'Malformed complete XFE_RENDER_STATS record at line {line_number}: {reason}; {line[:512]!r}')
        fields = line.split('|')
        if fields[0] != 'XFE_RENDER_STATS' or len(fields) < 4 or len(fields) % 2:
            malformed('expected a timestamp followed by complete name/value pairs')
        def integer(value, name):
            if re.fullmatch(r'[0-9]+', value) is None:
                malformed(name + ' must be an ASCII nonnegative integer')
            try: parsed = int(value)
            except ValueError: malformed(name + ' is outside the supported integer range')
            if parsed > 9_223_372_036_854_775_807:
                malformed(name + ' exceeds the native signed 64-bit range')
            return parsed
        value = {'Timestamp': integer(fields[1], 'Timestamp')}
        for index in range(2, len(fields), 2):
            name = fields[index]
            if re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', name) is None:
                malformed('invalid field name ' + repr(name))
            if name in value: malformed('duplicate field ' + name)
            value[name] = integer(fields[index + 1], name)
        missing = required.difference(value)
        if missing: malformed('missing required fields ' + ', '.join(sorted(missing)))
        if value['Frequency'] <= 0: malformed('Frequency must be greater than zero')
        result.append(value)
    return result

def run_desktop_checks(qmp, read_log, wait_log, screenshot, native_width, native_height, scale, pointer, input_rate=500, target_hz=240, desktop_v4=False):
    assert 240 <= input_rate <= 2000
    checks = []
    def key(code, down):
        qmp('input-send-event', {'events': [{'type': 'key', 'data': {'down': down, 'key': {'type': 'qcode', 'data': code}}}]})
        time.sleep(.04)
    def tap(code):
        key(code, True); key(code, False)
        time.sleep(.08)
    def chord(modifier, code):
        key(modifier, True); tap(code); key(modifier, False)
    def move(x, y):
        target = [round(x * scale), round(y * scale)]
        while pointer != target:
            dx = max(-80, min(80, target[0] - pointer[0])); dy = max(-80, min(80, target[1] - pointer[1]))
            qmp('input-send-event', {'events': [
                {'type': 'rel', 'data': {'axis': 'x', 'value': dx}},
                {'type': 'rel', 'data': {'axis': 'y', 'value': dy}}]})
            pointer[0] += dx; pointer[1] += dy
            time.sleep(.018)
    def button(down):
        qmp('input-send-event', {'events': [{'type': 'btn', 'data': {'down': down, 'button': 'left'}}]})
        time.sleep(.065)
    def click(x, y):
        move(x, y); button(True); button(False)
    def double_click(x, y):
        move(x, y)
        button(True); button(False); button(True); button(False)
    def drag(x, y, target_x, target_y):
        move(x, y); button(True); move(target_x, target_y); button(False)
    def states(start):
        result = []
        for match in STATE.finditer(read_log()[start:]):
            groups = match.groups()
            result.append(dict(zip(('Id', 'Application', 'X', 'Y', 'Width', 'Height', 'Minimized', 'Maximized', 'Snapped'),
                (int(groups[0]), groups[1], *(int(value) for value in groups[2:])))))
        return result
    def wait_state(start, predicate, description):
        deadline = time.monotonic() + 10
        while time.monotonic() < deadline:
            for state in reversed(states(start)):
                if predicate(state): return state
            log = read_log()
            assert '[FAILFAST]' not in log and '*** UNHANDLED EXCEPTION' not in log, description
            time.sleep(.04)
        raise AssertionError(description + ': no matching real guest state\n' + read_log()[start:][-5000:])
    def app_open(application, x, y):
        start = len(read_log()); click(x, y)
        value = wait_state(start, lambda state: state['Application'] == application and not state['Minimized'], 'Open ' + application)
        wait_log('XFE_DESKTOP_ACTIVE|' + application, start)
        return value
    def capture(name):
        time.sleep(.12)
        path = screenshot(name)
        assert path is not None, 'The screenshot callback must return its saved native PNG path'
        with Image.open(path) as saved:
            assert saved.size == (native_width, native_height), saved.size
            return saved.convert('RGB').copy()
    def raw_frame(name):
        return screenshot(name, convert=False)
    def open_raw(path):
        with Image.open(path) as saved:
            assert saved.size == (native_width, native_height)
            frame = saved.convert('RGB').copy()
            frame.save(path.with_suffix('.png'))
            return frame
    def changed_pixels(before, after, bounds, excluded=()):
        left, top, width, height = bounds
        box = (round(left * scale), round(top * scale), round((left + width) * scale), round((top + height) * scale))
        difference = ImageChops.difference(before, after)
        if excluded:
            erase = ImageDraw.Draw(difference)
            for x, y in excluded:
                erase.rectangle((round((x - 25) * scale), round((y - 25) * scale), round((x + 25) * scale), round((y + 25) * scale)), fill=(0, 0, 0))
        return sum(max(pixel) > 3 for pixel in difference.crop(box).getdata())
    def assert_hover_press(name, bounds, point):
        away = (round(native_width / scale) - 35, 80)
        move(*away); idle = capture(name + '-Idle')
        move(*point); hovered = capture(name + '-Hover')
        button(True); pressed = capture(name + '-Pressed')
        hover_changes = changed_pixels(idle, hovered, bounds, (away, point))
        press_changes = changed_pixels(hovered, pressed, bounds, (point,))
        assert hover_changes > 12, (name, 'hover background outside the cursor did not change', hover_changes)
        assert press_changes > 12, (name, 'pressed background outside the cursor did not change', press_changes)
        button(False)
        return {'HoverChangedNativePixels': hover_changes, 'PressedChangedNativePixels': press_changes}
    def caption_motion(window, name, offset, field):
        left = window['X'] + window['Width'] - offset
        hover = (left - 14, window['Y'] + 29)
        away = (left - 14, window['Y'] + 37)
        roi = (round((left - 9) * scale), round((window['Y'] + 7) * scale), round((left + 9) * scale), round((window['Y'] + 21) * scale))
        move(*away); time.sleep(.25)
        idle = raw_frame(name + '-Idle')
        start = len(read_log())
        captures = []
        def progress_values():
            return [int(match[field]) for match in CHROME.finditer(read_log()[start:]) if int(match[1]) == window['Id']]
        def wait_progress(predicate, description):
            deadline = time.monotonic() + 3
            while time.monotonic() < deadline:
                values = progress_values()
                if values and predicate(values): return values[-1]
                time.sleep(.002)
            raise AssertionError((name, description, progress_values()))
        def paused_raw(label):
            # A 3840x2160 PPM writes ~24 MiB. Even without PNG compression its
            # disk/QMP round trip can outlast the remaining reverse animation.
            # Freeze the *already presented real frame* only for inspection;
            # no guest clock or animation state is changed by this harness.
            before_stop = time.monotonic(); qmp('stop'); stopped = time.monotonic()
            try:
                values = progress_values()
                path = raw_frame(label)
                captured = time.monotonic()
            finally:
                qmp('cont')
            captures.append({'Name': label, 'RenderedProgressPermilleBeforeCapture': values[-1] if values else None,
                'StopRoundTripMilliseconds': (stopped - before_stop) * 1000,
                'PpmWriteMilliseconds': (captured - stopped) * 1000,
                'InspectionPauseMilliseconds': (time.monotonic() - stopped) * 1000,
                'NativePpmBytes': path.stat().st_size})
            return path
        move(*hover)
        wait_progress(lambda values: 100 <= values[-1] <= 450, 'No real entering intermediate frame')
        entering = paused_raw(name + '-Entering-Mid')
        # Prepare a longer but unfinished entering phase before reversing.
        # The sampled entering frame and reverse request need not share one
        # short 55 ms interval while a full-resolution PPM is still writing.
        reverse_requested_from = wait_progress(lambda values: 500 <= values[-1] <= 850,
            'No unfinished entering phase before reversal')
        move(*away)
        def descending_non_symmetric(values):
            if len(values) < 2 or not 0 < values[-1] < values[-2] < 1000: return False
            # X is rotationally symmetric at progress 0, 500 and 1000. Select
            # an actual descending frame away from those visually equal poses.
            return 50 <= values[-1] <= 950 and (field != 3 or not 420 <= values[-1] <= 580)
        wait_progress(descending_non_symmetric, 'No real descending intermediate frame')
        reversing = paused_raw(name + '-Reversing-Mid')
        time.sleep(.25)
        restored = raw_frame(name + '-Restored')
        move(*hover); time.sleep(.25)
        completed = raw_frame(name + '-Completed')
        move(*away); time.sleep(.25)
        paths = (idle, entering, reversing, restored, completed)
        frames = [open_raw(path).crop(roi) for path in paths]
        def difference(first, last):
            return sum(max(pixel) > 3 for pixel in ImageChops.difference(first, last).getdata())
        entered = difference(frames[0], frames[1]); reversed_mid = difference(frames[0], frames[2])
        mid_to_end = difference(frames[1], frames[4]); direction_change = difference(frames[1], frames[2])
        restored_difference = difference(frames[0], frames[3])
        values = []
        for match in CHROME.finditer(read_log()[start:]):
            if int(match[1]) == window['Id']: values.append(int(match[field]))
        intermediate = [value for value in values if 0 < value < 1000]
        descending = any(0 < last < first < 1000 for first, last in zip(values, values[1:]))
        assert entered > 3 and reversed_mid > 3 and mid_to_end > 3 and direction_change > 3, (name, 'missing real intermediate/reverse pixels', entered, reversed_mid, mid_to_end, direction_change)
        assert restored_difference == 0 and intermediate and descending, (name, 'animation jumped or failed to reverse smoothly', restored_difference, values)
        return {'EnteringChangedPixels': entered, 'ReverseIntermediateChangedPixels': reversed_mid,
            'EnteringToEndChangedPixels': mid_to_end, 'EnteringToReverseChangedPixels': direction_change,
            'RestoredDifferencePixels': restored_difference, 'ProgressPermille': values,
            'HasDescendingIntermediate': descending, 'RawCaptureBeforePngCompression': True,
            'CaptureSelection': 'Actual presented intermediate/descending Chrome telemetry; inspection-only QMP pause during native PPM write',
            'ReverseRequestedFromPermille': reverse_requested_from, 'IntermediateCaptures': captures,
            'Screenshots': [path.with_suffix('.png').name for path in paths]}
    visual = {}

    # Close only windows in this test guest; the existing user-owned VM is never addressed.
    for _ in range(15): tap('esc')
    if desktop_v4:
        move(round(native_width / scale) - 35, 80)
        desktop_frame = capture('Desktop-No-Topbar-Native')
        top_pixel = desktop_frame.getpixel((native_width // 2, round(15 * scale)))
        empty_pixel = desktop_frame.getpixel((native_width - round(140 * scale), round(70 * scale)))
        # The wallpaper now has a vertical gradient. Verify horizontal
        # continuity and the absence of a sharp header edge, rather than
        # requiring two different heights to have the old flat color.
        strip = [(desktop_frame.getpixel((native_width // 2, round(y * scale))),
                  desktop_frame.getpixel((native_width - round(140 * scale), round(y * scale))))
                 for y in range(15, 71)]
        assert all(left == right for left, right in strip), 'Unexpected top-strip chrome or text over the wallpaper'
        maximum_step = max(abs(strip[index][0][channel] - strip[index - 1][0][channel])
                           for index in range(1, len(strip)) for channel in range(3))
        assert maximum_step <= 1, ('A sharp system top-bar edge remains in the gradient', maximum_step)
        visual['NoTopbar'] = {'NativeTopPixel': top_pixel, 'NativeDesktopPixel': empty_pixel,
                             'HorizontalContinuity': True, 'MaximumVerticalChannelStep': maximum_step}
        checks.append('desktop top is clear wallpaper with no system top bar')
    start = len(read_log())
    visual['DesktopButton'] = assert_hover_press('Desktop-Files-Button', (22, 84, 100, 74), (30, 108))
    files = wait_state(start, lambda state: state['Application'] == 'Files' and not state['Minimized'], 'Desktop button opens Files')
    checks.append('desktop button hover and press change real native pixels outside the cursor')
    original = files.copy()
    start = len(read_log())
    double_click(files['X'] + files['Width'] // 2, files['Y'] + 16)
    maximized = wait_state(start, lambda state: state['Id'] == files['Id'] and state['Maximized'], 'Title double-click maximizes')
    assert (maximized['X'], maximized['Y'], maximized['Width'], maximized['Height']) == (0, 0 if desktop_v4 else 42, round(native_width / scale), round(native_height / scale) - (48 if desktop_v4 else 90)), maximized
    checks.append('title double-click maximizes to work area without covering taskbar')
    screenshot('Window-Maximized')
    start = len(read_log())
    double_click(maximized['Width'] // 2, maximized['Y'] + 16)
    files = wait_state(start, lambda state: state['Id'] == files['Id'] and not state['Maximized'], 'Title double-click restores')
    assert all(files[field] == original[field] for field in ('X', 'Y', 'Width', 'Height')), (original, files)
    checks.append('second title double-click restores exact bounds')

    start = len(read_log())
    drag(files['X'] + 110, files['Y'] + 16, files['X'] + 150, files['Y'] + 46)
    files = wait_state(start, lambda state: state['Id'] == files['Id'] and state['X'] == original['X'] + 40 and state['Y'] == original['Y'] + 30, 'Title drag follows pointer')
    checks.append('title drag moves the window while preserving size')
    before_resize = files.copy(); start = len(read_log())
    drag(files['X'] + files['Width'] - 9, files['Y'] + files['Height'] - 9,
         files['X'] + files['Width'] + 71, files['Y'] + files['Height'] + 41)
    expected_width = min(before_resize['Width'] + 80, round(native_width / scale) - before_resize['X'])
    expected_height = min(before_resize['Height'] + 50, round(native_height / scale) - 48 - before_resize['Y'])
    files = wait_state(start, lambda state: state['Id'] == files['Id'] and state['Width'] == expected_width and state['Height'] == expected_height, 'Bottom-right resize')
    checks.append('bottom-right drag resizes with work-area constraints')
    screenshot('Window-Resized')

    if desktop_v4:
        visual['CaptionMotion'] = {'Minimize': caption_motion(files, 'Caption-Minimize', 48, 2),
                                   'Close': caption_motion(files, 'Caption-Close', 16, 3)}
        checks.append('minimize hover shows a real intermediate rotation and reverses continuously before settling')
        checks.append('close hover captures intermediate X rotation and continuous reverse before its symmetric endpoint')

    visual['FilesToolbar'] = assert_hover_press('Files-Up-Button', (files['X'] + 6, files['Y'] + 36, 54, 26), (files['X'] + 8, files['Y'] + 38))
    checks.append('Files toolbar hover and press change real native pixels outside the cursor')
    move(round(native_width / scale) - 35, 80)
    caption_frame = capture('Window-Caption-TwoButtons')
    title_color = caption_frame.getpixel((round((files['X'] + files['Width'] // 2 + 50) * scale), round((files['Y'] + 14) * scale)))
    old_square = caption_frame.crop((round((files['X'] + files['Width'] - 57) * scale), round((files['Y'] + 9) * scale),
                                    round((files['X'] + files['Width'] - 37) * scale), round((files['Y'] + (13 if desktop_v4 else 17)) * scale)))
    former_square_pixels = sum(max(abs(pixel[channel] - title_color[channel]) for channel in range(3)) > 3 for pixel in old_square.getdata())
    assert former_square_pixels == 0, ('A maximize-square glyph remains above the new minimize line', former_square_pixels)
    corner_box = (round(files['X'] * scale), round(files['Y'] * scale), round((files['X'] + 12) * scale), round((files['Y'] + 12) * scale))
    corner_colors = len(set(caption_frame.crop(corner_box).getdata()))
    assert corner_colors >= 8, ('Window corner does not expose blended antialiasing coverage at native resolution', corner_colors)
    visual['Caption'] = {'FormerMaximizeSquarePixels': former_square_pixels, 'NativeCornerDistinctColors': corner_colors, 'NativeScreenshot': 'Window-Caption-TwoButtons.png'}
    checks.append('caption shows minimize and close without the former maximize-square glyph')
    checks.append('native rounded window corner contains blended antialiasing coverage colors')

    taskbar_y = round(native_height / scale) - 24
    start = len(read_log()); click(100, taskbar_y)
    wait_state(start, lambda state: state['Id'] == files['Id'] and state['Minimized'], 'Taskbar minimizes active window')
    start = len(read_log()); click(100, taskbar_y)
    restored = wait_state(start, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Taskbar restores window')
    assert all(restored[field] == files[field] for field in ('X', 'Y', 'Width', 'Height')), (files, restored)
    checks.append('taskbar minimizes and restores the same live window and bounds')

    terminal = app_open('Terminal', 60, 402)
    browser = app_open('Browser', 60, 308)
    start = len(read_log()); key('alt', True)
    for application in ('Terminal', 'Files', 'Browser', 'Terminal'):
        step = len(read_log()); tap('tab'); wait_log('XFE_DESKTOP_ACTIVE|' + application, step)
    key('alt', False)
    checks.append('held Alt+Tab cycles through three windows repeatedly and commits on Alt release')
    start = len(read_log()); key('alt', True); key('shift', True); tap('tab')
    wait_log('XFE_DESKTOP_ACTIVE|Files', start)
    key('shift', False); key('alt', False)
    checks.append('Alt+Shift+Tab selects the previous window in MRU order')
    screenshot('Window-AltTab-Committed')

    start = len(read_log()); chord('meta_l', 'd')
    for target in (files, terminal, browser):
        wait_state(start, lambda state, identity=target['Id']: state['Id'] == identity and state['Minimized'], 'Raw Win+D hides each live window')
    start = len(read_log()); chord('meta_l', 'd')
    for target in (files, terminal, browser):
        wait_state(start, lambda state, identity=target['Id']: state['Id'] == identity and not state['Minimized'], 'Raw Win+D restores each live window')
    checks.append('raw Win+D toggles desktop without losing window state')

    start = len(read_log()); tap('meta_l'); tap('ret')
    opened = wait_state(start, lambda state: state['Application'] == 'Files' and state['Id'] > max(files['Id'], terminal['Id'], browser['Id']), 'Raw Win opens launcher; Enter opens Files')
    checks.append('raw Win make/break opens the launcher and Enter starts its selected application')
    start = len(read_log()); chord('alt', 'f4')
    wait_log('XFE_DESKTOP_ACTIVE|Files', start)
    checks.append('raw Alt+F4 closes the foreground launcher window and returns to previous window')
    taskbar_frame = capture('Window-Taskbar-Final')
    sample_x = native_width - round(140 * scale)
    taskbar_pixel = taskbar_frame.getpixel((sample_x, native_height - round(8 * scale)))
    desktop_pixel = taskbar_frame.getpixel((sample_x, native_height - round(60 * scale)))
    assert taskbar_pixel != desktop_pixel, ('Native bottom taskbar band is absent', taskbar_pixel, desktop_pixel)
    visual['NativeTaskbarBand'] = {'NativeBottomPixel': taskbar_pixel, 'NativeDesktopPixel': desktop_pixel, 'FrameSize': [native_width, native_height], 'HostViewportMeasured': False}
    checks.append('native framebuffer includes the full bottom taskbar band at unchanged resolution')
    def render_stats():
        return parse_render_stats(read_log())
    def next_stats(previous_timestamp=-1):
        deadline = time.monotonic() + 6
        while time.monotonic() < deadline:
            values = render_stats()
            if values and values[-1]['Timestamp'] > previous_timestamp: return values[-1]
            time.sleep(.02)
        raise AssertionError('Guest render statistics did not advance')
    def measure(begin, end, duration, dispatch_count, raw_events_per_dispatch, host_sample_seconds):
        elapsed = (end['Timestamp'] - begin['Timestamp']) / end['Frequency']
        frames = end['Frames'] - begin['Frames']; pixels = end['Pixels'] - begin['Pixels']
        assert elapsed > 0 and frames > 0, (begin, end)
        assert end['Fallback'] - begin['Fallback'] == 0, (begin, end)
        assert end['Partial'] - begin['Partial'] > 0, (begin, end)
        active = []
        observed = [value for value in render_stats() if begin['Timestamp'] <= value['Timestamp'] <= end['Timestamp']]
        observed_gaps = [(last['Timestamp'] - first['Timestamp']) / last['Frequency'] for first, last in zip(observed, observed[1:])]
        assert all(gap > 0 for gap in observed_gaps), ('Native statistics timestamps did not advance', observed)
        maximum_observed_gap = max(observed_gaps, default=0)
        values = [value for value in observed if value['Timestamp'] <= begin['Timestamp'] + round(duration * begin['Frequency'])]
        for first, last in zip(values, values[1:]):
            if first['Timestamp'] <= begin['Timestamp']: continue
            interval = (last['Timestamp'] - first['Timestamp']) / last['Frequency']
            count = last['Frames'] - first['Frames']
            copied_pixels = last['Pixels'] - first['Pixels']
            assert count >= 0 and copied_pixels >= 0 and (count > 0 or copied_pixels == 0), (first, last)
            # A complete interval with no frame is observed stutter, and must
            # remain in the distribution instead of inflating its median FPS.
            active.append({'Seconds': interval, 'Frames': count, 'RendererFramesPerSecond': count / interval,
                'AverageCopiedPixelsPerFrame': copied_pixels / count if count else 0,
                'FullCopyFallbacks': last['Fallback'] - first['Fallback']})
        assert active, ('No complete stable activity window was sampled',
                        {'CompleteRecordsIncludingBoundaries':len(observed),'MaximumObservedStatsGapSeconds':maximum_observed_gap,
                         'BeginTimestamp':begin['Timestamp'],'EndTimestamp':end['Timestamp'],'InputMotionSeconds':duration})
        return {'SampleWindowSecondsIncludingIdle': elapsed, 'InputMotionSeconds': duration, 'FramesIncludingIdleWindow': frames,
            'RendererFramesPerSecond': frames / elapsed, 'AverageCopiedPixelsPerFrame': pixels / frames,
            'WholeWindowIncludesTrailingIdle': True, 'ActiveStableWindows': active,
            'ActiveStableMedianRendererFramesPerSecond': statistics.median(value['RendererFramesPerSecond'] for value in active),
            'StableWindowRatesIncludeZeroFrameIntervals': True, 'ZeroFrameStableWindows':sum(value['Frames']==0 for value in active),
            'MaximumStableStatsGapSeconds':max(value['Seconds'] for value in active),
            'MaximumObservedStatsGapSeconds':maximum_observed_gap,
            'HostSampleWindowSecondsIncludingIdle': host_sample_seconds, 'HostWholeWindowRendererFramesPerSecond': frames / host_sample_seconds,
            'HostInputDispatchesSent': dispatch_count, 'HostInputDispatchesPerSecond': dispatch_count / duration,
            'HostRawInputEventsSent': dispatch_count * raw_events_per_dispatch,
            'HostRawInputEventsPerSecond': dispatch_count * raw_events_per_dispatch / duration, 'HostInputDispatchRateRequested': input_rate,
            'FullScreenPixels': native_width * native_height,
            'PartialFrames': end['Partial'] - begin['Partial'], 'FullCopyFallbacks': end['Fallback'] - begin['Fallback'],
            'MotionReuses': end.get('MotionReuses', 0) - begin.get('MotionReuses', 0),
            'MotionPixels': end.get('MotionPixels', 0) - begin.get('MotionPixels', 0),
            'LastRenderMilliseconds': end['LastRenderTicks'] * 1000 / end['Frequency'],
            'TargetHz': target_hz, 'TargetMeaning': 'EDID renderer target; this is not a VSync or guaranteed presentation-rate measurement'}
    def timed_input(send_event):
        began = time.monotonic(); index = 0
        while time.monotonic() - began < 3.1:
            send_event(index); index += 1
            due = began + index / input_rate
            delay = due - time.monotonic()
            if delay > 0: time.sleep(delay)
        return time.monotonic() - began, index
    # The right side is unobstructed at both supported 1280/800 and 4K/200% layouts.
    move(round(native_width / scale) - 35, 80)
    baseline_stats = next_stats(render_stats()[-1]['Timestamp'] if render_stats() else -1)
    baseline_wall = time.monotonic()
    def cursor_event(index):
        dx = 2 if (index // 12) % 2 == 0 else -2
        qmp('input-send-event', {'events': [{'type': 'rel', 'data': {'axis': 'x', 'value': dx}}]})
        pointer[0] += dx
    duration, events = timed_input(cursor_event)
    end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
    cursor_stats = measure(baseline_stats, end_stats, duration, events, 1, time.monotonic() - baseline_wall)
    assert cursor_stats['AverageCopiedPixelsPerFrame'] < native_width * native_height / 10, cursor_stats
    cursor_position = (round(native_width / scale) - 35, 80)
    move(cursor_position[0], cursor_position[1] + 70)
    arrow_background = capture('Cursor-Arrow-Background')
    move(*cursor_position)
    arrow_frame = capture('Cursor-Arrow-Native')
    cx, cy = (round(value * scale) for value in cursor_position)
    radius = round(25 * scale)
    arrow_box = (cx - radius, cy - radius, min(native_width, cx + radius + 1), cy + radius + 1)
    before_pixels = arrow_background.crop(arrow_box).getdata()
    arrow_pixels = arrow_frame.crop(arrow_box).getdata()
    colored = [pixel for old, pixel in zip(before_pixels, arrow_pixels) if max(abs(pixel[channel] - old[channel]) for channel in range(3)) > 3]
    distinct_colors = len(set(colored))
    blue_core_pixels = sum(pixel[2] - pixel[0] > 25 and pixel[2] - pixel[1] > 12 for pixel in colored)
    assert distinct_colors >= 16 and blue_core_pixels >= 12, ('Arrow needs continuous antialiased blue core and blended halo', distinct_colors, blue_core_pixels)
    visual['ArrowCursor'] = {'ChangedNativePixels': len(colored), 'DistinctBlendedColors': distinct_colors, 'BlueCorePixels': blue_core_pixels, 'NativeScreenshot': 'Cursor-Arrow-Native.png'}
    checks.append('native arrow cursor retains a blue core and at least sixteen blended edge/halo colors')
    checks.append('cursor-only native-resolution movement uses damaged copy with zero full-copy fallback')

    editor = app_open('Editor', 60, 214)
    # Every line differs; saving its exact length confirms that burst key events were consumed.
    source = ''.join('row%02d\n' % index for index in range(80))
    for character in source:
        code = 'ret' if character == '\n' else character
        qmp('input-send-event', {'events': [
            {'type': 'key', 'data': {'down': True, 'key': {'type': 'qcode', 'data': code}}},
            {'type': 'key', 'data': {'down': False, 'key': {'type': 'qcode', 'data': code}}}]})
        time.sleep(.012)
    start = len(read_log()); chord('ctrl', 's')
    deadline = time.monotonic() + 10
    while time.monotonic() < deadline:
        if re.search(r'^XFE_DESKTOP_SAVED\|[^\r\n]+\|' + str(len(source)) + r'$', read_log()[start:], re.M): break
        time.sleep(.04)
    else: raise AssertionError('The scroll-test document did not save its expected length')
    move(editor['X'] + 150, editor['Y'] + 150)
    screenshot('Cursor-Text-Native')
    baseline_stats = next_stats(render_stats()[-1]['Timestamp'])
    baseline_wall = time.monotonic()
    def scroll_event(index):
        wheel = 'wheel-up' if (index // 4) % 2 == 0 else 'wheel-down'
        qmp('input-send-event', {'events': [
            {'type': 'btn', 'data': {'down': True, 'button': wheel}},
            {'type': 'btn', 'data': {'down': False, 'button': wheel}}]})
    duration, events = timed_input(scroll_event)
    end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
    scroll_stats = measure(baseline_stats, end_stats, duration, events, 2, time.monotonic() - baseline_wall)
    screenshot('Scroll-LongDocument-Native')
    checks.append('long-document wheel scrolling retains partial scanout and zero full-copy fallback')
    move(editor['X'] + editor['Width'] - 9, editor['Y'] + editor['Height'] - 9)
    screenshot('Cursor-Resize-Native')

    settings_point = (50, 492) if desktop_v4 else (round(native_width / scale) - 60, 22)
    settings = app_open('Settings', *settings_point)
    # Fresh desktops now start dark. Make the following Dark click an actual
    # transition so the existing state/hover/pixel checks still verify a change.
    preparation_start = len(read_log())
    click(settings['X'] + 28, settings['Y'] + 93)
    wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', preparation_start)
    dark_start = len(read_log())
    visual['SettingsDarkButton'] = assert_hover_press('Settings-Dark-Button',
        (settings['X'] + 140, settings['Y'] + 88, 104, 34), (settings['X'] + 144, settings['Y'] + 93))
    wait_log('XFE_DESKTOP_APPEARANCE|Dark|657DE2|Persistent|0', dark_start)
    move(round(native_width / scale) - 35, 80)
    dark_frame = capture('Settings-Dark-Native')
    corner = (native_width - round(140 * scale), native_height - round(60 * scale))
    dark_pixel = dark_frame.getpixel(corner)
    assert sum(dark_pixel) / 3 < 100, ('Dark theme did not darken the native desktop', dark_pixel)
    checks.append('Settings Dark button hover/press and native dark desktop pixels agree with guest appearance state')

    light_start = len(read_log())
    visual['SettingsLightButton'] = assert_hover_press('Settings-Light-Button',
        (settings['X'] + 24, settings['Y'] + 88, 104, 34), (settings['X'] + 28, settings['Y'] + 93))
    wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', light_start)
    move(round(native_width / scale) - 35, 80)
    light_frame = capture('Settings-Light-Native')
    light_pixel = light_frame.getpixel(corner)
    assert sum(light_pixel) / 3 > 200 and light_pixel != dark_pixel, ('Light theme did not restore the native desktop', light_pixel, dark_pixel)
    checks.append('Settings Light button hover/press restores a visibly light native desktop')

    visual['SettingsAccentField'] = assert_hover_press('Settings-Accent-Field',
        (settings['X'] + 24, settings['Y'] + 202, 220, 34), (settings['X'] + 28, settings['Y'] + 207))
    custom_start = len(read_log())
    chord('shift', '3')
    for code in '14a8b4': tap(code)
    tap('ret')
    wait_log('XFE_DESKTOP_APPEARANCE|Light|14A8B4|Persistent|0', custom_start)
    move(round(native_width / scale) - 35, 80)
    accent_frame = capture('Settings-Custom-Accent-Native')
    accent_pixels = changed_pixels(light_frame, accent_frame, (settings['X'] + 20, settings['Y'] + 38, 280, 25) if desktop_v4 else (45, 8, 250, 28))
    assert accent_pixels > 20, ('Guest accent state changed without redrawing real title ink', accent_pixels)
    visual['Appearance'] = {'DarkDesktopPixel': dark_pixel, 'LightDesktopPixel': light_pixel,
        'CustomAccent': '#14A8B4', 'ChangedAccentNativePixels' if desktop_v4 else 'ChangedHeaderNativePixels': accent_pixels, 'Persistent': False}
    checks.append('Settings accent field hover/press and custom RGB keyboard input recolor real native title pixels')

    invalid_start = len(read_log())
    click(settings['X'] + 28, settings['Y'] + 207)
    chord('shift', '3'); tap('1'); tap('2'); tap('ret')
    capture('Settings-Invalid-Accent-Native')
    latest = APPEARANCE.findall(read_log())[-1]
    assert latest == ('Light', '14A8B4', '0'), ('Invalid short accent changed the actual appearance', latest)
    assert all(match == latest for match in APPEARANCE.findall(read_log()[invalid_start:])), 'Invalid accent emitted a different appearance'
    checks.append('invalid short accent is rejected while preserving the prior appearance')

    reset_start = len(read_log()); click(settings['X'] + 44, settings['Y'] + 165)
    wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', reset_start)
    chord('alt', 'f4')
    settings = app_open('Settings', *settings_point)
    capture('Settings-Ram-Appearance-Reopened')
    assert APPEARANCE.findall(read_log())[-1] == ('Light', '657DE2', '0')
    checks.append('Blue preset restores the original accent and reopening Settings retains the RAM session appearance')
    performance = {'Cursor': cursor_stats, 'Scroll': scroll_stats}
    if desktop_v4:
        chord('alt', 'f4')
        step = len(read_log()); click(100, round(native_height / scale) - 24)
        files = wait_state(step, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Activate Files for native motion')
        for kind in ('Drag', 'Resize'):
            if kind == 'Drag': move(files['X'] + 120, files['Y'] + 16)
            else: move(files['X'] + files['Width'] - 9, files['Y'] + files['Height'] - 9)
            button(True)
            baseline_stats = next_stats(render_stats()[-1]['Timestamp'])
            baseline_wall = time.monotonic()
            def motion_event(index):
                delta = 2 if (index // 12) % 2 == 0 else -2
                events = [{'type': 'rel', 'data': {'axis': 'x', 'value': delta}}]
                pointer[0] += delta
                if kind == 'Resize':
                    events.append({'type': 'rel', 'data': {'axis': 'y', 'value': delta}}); pointer[1] += delta
                qmp('input-send-event', {'events': events})
            duration, events = timed_input(motion_event)
            end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
            performance[kind] = measure(baseline_stats, end_stats, duration, events, 1 if kind == 'Drag' else 2, time.monotonic() - baseline_wall)
            step = len(read_log()); button(False)
            # At dense input the bounded guest drain can publish intermediate
            # bounds while the actual release is still queued. Correlate with
            # its real dispatch acknowledgement before reading final bounds;
            # do not treat any same-window report as a completed gesture.
            release_deadline = time.monotonic() + 10
            while time.monotonic() < release_deadline:
                release = re.search(r'^XFE_NATIVE_POINTER\|[^\r\n]*\|Left\|0\|[^\r\n]*\r?\n', read_log()[step:], re.M)
                if release:
                    released_at = step + release.end()
                    break
                time.sleep(.01)
            else:
                raise AssertionError(kind + ': actual native pointer release was not acknowledged')
            files = wait_state(released_at, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Native motion settles after release')
            if kind == 'Drag': assert performance[kind]['MotionReuses'] > 0 and performance[kind]['MotionPixels'] > 0, 'Native pointer-free body reuse was not exercised'
            move(round(native_width / scale) - 35, 80); time.sleep(.3)
            partial_frame = capture('Window-' + kind + '-Settled')
            restore_start = len(read_log()); chord('meta_l', 'd'); chord('meta_l', 'd')
            restored_state = wait_state(restore_start, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Win+D restores motion state for full redraw comparison')
            assert all(restored_state[key] == files[key] for key in ('X', 'Y', 'Width', 'Height', 'Maximized', 'Snapped')), (kind, files, restored_state)
            time.sleep(.3)
            redrawn_frame = capture('Window-' + kind + '-FullRedraw')
            residue = sum(any(pixel) for pixel in ImageChops.difference(partial_frame, redrawn_frame).getdata())
            assert residue == 0, (kind, 'motion left pixels differing from forced full redraw', residue)
            visual[kind + 'Motion'] = {'FullRedrawDifferencePixels': residue, 'SameWindowBounds': True, 'HostViewportMeasured': False}
            checks.append('native ' + kind.lower() + ' motion is measured at dense input and settles without any residue against full redraw')
    return {'Checks': checks, 'Performance': performance, 'VisualEvidence': visual,
            'PerformanceGoalHz': target_hz, 'VSyncConfirmed': False}

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--resolution', default='0x0')
    parser.add_argument('--edid-resolution', default='3840x2160')
    parser.add_argument('--refresh-hint', type=int, default=240)
    parser.add_argument('--accel', choices=['tcg', 'whpx'], default='tcg')
    parser.add_argument('--input-rate', type=int, default=500)
    parser.add_argument('--desktop-ui-only', action='store_true')
    parser.add_argument('--desktop-v4', action='store_true')
    parser.add_argument('--verified-image')
    parser.add_argument('--verified-boot-result')
    options = parser.parse_args()
    test = pathlib.Path(__file__).resolve().parent / 'CSharpBoot/TestBoot.py'
    arguments = [sys.executable, str(test), '--desktop-ui', '--resolution', options.resolution,
        '--edid-resolution', options.edid_resolution, '--refresh-hint', str(options.refresh_hint), '--video-memory-mib', '64',
        '--accel', options.accel, '--input-rate', str(options.input_rate)]
    if options.verified_image: arguments += ['--verified-image', options.verified_image]
    if options.verified_boot_result: arguments += ['--verified-boot-result', options.verified_boot_result]
    if options.desktop_ui_only: arguments += ['--desktop-ui-only']
    if options.desktop_v4: arguments += ['--desktop-v4']
    return subprocess.call(arguments)

if __name__ == '__main__': raise SystemExit(main())
