"""Independent read-only checks of the real guest's C# DOM/font/color readback.

Expected colors come from the unchanged website CSS and the committed derived
palette; no image or token is synthesized and no guest state is injected.
"""
import hashlib
import json
import pathlib
import re


ROOT = pathlib.Path(__file__).resolve().parents[1]
CSHARP_PROOF = re.compile(r'XFE_MONACO_CSHARP_PROOF_BEGIN\r?\n(.*?)\r?\nXFE_MONACO_CSHARP_PROOF_END', re.S)


def website_palette():
    directory = ROOT / 'assets/Browser/WebsiteCode'
    css_bytes = (directory / 'CodeStyle.css').read_bytes()
    manifest = json.loads((directory / 'Manifest.json').read_text('utf-8-sig'))
    css_entry = next(entry for entry in manifest['Files'] if entry['Path'] == 'CodeStyle.css')
    css_hash = hashlib.sha256(css_bytes).hexdigest()
    assert css_entry['Sha256'] == css_hash and css_entry['Verbatim'] is True
    script = (directory / 'CodePalette.js').read_text('utf-8-sig').strip()
    prefix = 'globalThis.xfeWebsiteCodePalette='
    assert script.startswith(prefix) and script.endswith(';')
    assert hashlib.sha256((directory / 'CodePalette.js').read_bytes()).hexdigest() == manifest['DerivedPalette']['Sha256']
    palette = json.loads(script[len(prefix):-1])
    assert palette['CssSha256'] == css_hash
    assert palette['CascadeCssSha256'] == manifest['DerivedPalette']['CascadeCssSha256']
    for entry in palette['CssSources']:
        assert hashlib.sha256((ROOT / entry['SnapshotPath']).read_bytes()).hexdigest() == entry['Sha256'], ('Website cascade stylesheet changed', entry['UrlPath'])
    rules = []
    for rule in palette['Rules']:
        if 'Foreground' not in rule:
            continue
        scopes = rule['Scopes']
        token = rule.get('Token') or ('xfe' if scopes == ['hljs'] else 'xfe.' + '.'.join(scope.removeprefix('hljs-').replace('-', '_') for scope in scopes))
        rules.append((token, rule['Foreground'].lower()))
    assert dict(rules)['xfe.csharp_keyword'] == 'c586c0'
    assert dict(rules)['xfe.type'] == '4ec9b0'
    assert dict(rules)['xfe.string'] == 'ce9178' and dict(rules)['xfe.number'] == 'b5cea8'
    assert dict(rules)['xfe'] == 'f1f1f4' and dict(rules)['xfe.namespace'] == 'dcdcdc'
    assert dict(rules)['xfe.meta.string'] == 'd69d85'  # Higher specificity beats the later simple Log.css rule.
    return {'CssSha256': css_hash, 'CascadeCssSha256': palette['CascadeCssSha256'], 'Rules': rules, 'AssetManifest': manifest,
            'AssetManifestSha256': hashlib.sha256((directory / 'Manifest.json').read_bytes()).hexdigest()}


def normalized_color(value):
    value = value.lower().strip()
    if re.fullmatch(r'#[0-9a-f]{6}', value):
        return value[1:]
    if re.fullmatch(r'#[0-9a-f]{3}', value):
        return ''.join(character * 2 for character in value[1:])
    numbers = re.fullmatch(r'rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*1(?:\.0*)?)?\s*\)', value)
    assert numbers and all(0 <= int(component) <= 255 for component in numbers.groups()), ('Unexpected CSS color', value)
    return ''.join(f'{int(component):02x}' for component in numbers.groups())


def verify_readback(proof, palette, require_lines=12):
    assert proof['Kind'] == 'ActualMonacoWebsiteCSharpDomReadback'
    assert proof['Language'] == 'csharp' and proof['Truncated'] is False
    assert proof['HostScreenshotUsed'] is False and proof['WorkerLanguageService'] is False
    assert proof['PaletteCssSha256'] == palette['CssSha256']
    assert proof['PaletteCascadeSha256'] == palette['CascadeCssSha256']
    source = proof['ModelValue']
    assert proof['ModelLength'] == len(source) and len(source) <= 16384
    source_lines = source.split('\n')
    assert len(proof['TokenLines']) == len(source_lines)
    checked = []
    line_ids = set()
    colors = set()
    for line in proof['ViewLines']:
        index = line['Line'] - 1
        assert 0 <= index < len(source_lines)
        assert index not in line_ids, ('Duplicate actual view line', index)
        line_ids.add(index)
        assert line['Text'] == source_lines[index], ('Real model and view text differ', index + 1, line['Text'], source_lines[index])
        tokens = proof['TokenLines'][index]
        assert tokens and tokens[0]['startIndex'] == 0
        starts = [token['startIndex'] for token in tokens]
        assert starts == sorted(set(starts))
        offset = 0
        for span in line['Spans']:
            span_text = span['Text']
            if not span_text:
                continue
            actual = normalized_color(span['Color'])
            end = offset + len(span_text)
            assert source_lines[index][offset:end] == span_text
            # Monaco may combine adjacent scopes of the same color; verify
            # every source token interval intersecting this real DOM span.
            intersected = []
            for token_index, token in enumerate(tokens):
                token_end = starts[token_index + 1] if token_index + 1 < len(tokens) else len(source_lines[index])
                if token['startIndex'] >= end or token_end <= offset:
                    continue
                matches = [(name, rgb) for name, rgb in palette['Rules'] if token['scopes'] == name or token['scopes'].startswith(name + '.')]
                assert matches, ('No CSS foreground for website token', token['scopes'])
                expected = sorted(matches, key=lambda item: len(item[0]))[-1][1]
                assert actual == expected, ('Actual guest C# span color differs from website CSS', index + 1, offset, span_text, actual, expected, token['scopes'])
                intersected.append({'Scope': token['scopes'], 'ExpectedColor': expected})
            assert intersected, ('Real span has no matching website token', index, span)
            colors.add(actual)
            checked.append({'Line': index + 1, 'StartColumn': offset + 1, 'Text': span_text,
                            'ActualColor': actual, 'WebsiteTokens': intersected})
            offset = end
        assert offset == len(source_lines[index]), ('Not every actual rendered character was verified', index, offset)
    assert len(line_ids) >= require_lines and len(checked) >= 30
    assert {'569cd6', '4ec9b0', 'c586c0', 'ce9178', 'b5cea8'}.issubset(colors), ('Required C# categories missing from actual rendered text', colors)
    return {'Passed': True, 'CssSha256': palette['CssSha256'], 'RenderedLines': len(line_ids),
            'RenderedSpansChecked': len(checked), 'ActualRenderedColors': sorted(colors), 'Checks': checked,
            'ActualCssFontFamily': proof.get('FontFamily'), 'ActualCssFontSize': proof.get('FontSize'),
            'Meaning': 'Native DOM text/foreground readback; framebuffer ink is verified separately.'}


def _color_pixels(crop, rgb, tolerance=12):
    return sum(all(abs(pixel[channel] - rgb[channel]) <= tolerance for channel in range(3)) for pixel in crop.getdata())


def csharp_frame_stats(frame, viewport, scale, model, require_comment=False):
    """Real framebuffer bands only; the pointer/header/gutter stay outside ROI."""
    vx, vy, width, height = viewport
    source_lines = model.split('\n')
    css_bounds = (vx + 42, vy + 28, min(650, width - 42), min(len(source_lines) * 20, height - 28))
    x, y, w, h = css_bounds
    native = tuple(round(value * scale) for value in (x, y, x + w, y + h))
    crop = frame.crop(native).convert('RGB')
    expected = {'Keyword': '569cd6', 'Type': '4ec9b0', 'ControlFlow': 'c586c0', 'String': 'ce9178', 'Number': 'b5cea8'}
    if require_comment:
        expected['Comment'] = '57a64a'
    palette_pixels = {name: _color_pixels(crop, tuple(int(rgb[index:index + 2], 16) for index in (0, 2, 4))) for name, rgb in expected.items()}
    minimum = max(4, round(5 * scale * scale))
    rows = []
    for index, source in enumerate(source_lines):
        if len(source.strip()) < 8:
            continue
        bounds = tuple(round(value * scale) for value in (vx + 42, vy + 28 + index * 20, vx + min(width, 692), vy + 48 + index * 20))
        band = frame.crop(bounds).convert('RGB')
        pixels, columns = 0, set()
        for row in range(band.height):
            for column in range(band.width):
                rgb = band.getpixel((column, row))
                if .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2] >= 78:
                    pixels += 1; columns.add(column)
        rows.append({'Line': index + 1, 'NativeBounds': list(bounds), 'Text': source,
                     'InkPixels': pixels, 'InkColumns': len(columns),
                     'Passed': pixels >= 35 * scale * scale and len(columns) >= 24 * scale})
    return {'Passed': all(value >= minimum for value in palette_pixels.values()) and len(rows) >= 8 and all(row['Passed'] for row in rows),
            'CodeCssBounds': list(css_bounds), 'CodeNativeBounds': list(native), 'PalettePixelCounts': palette_pixels,
            'MinimumPixelsPerColor': minimum, 'PaletteRgbFromWebsiteCss': expected, 'LineInk': rows,
            'NoHostScreenshotSubstitution': True, 'PointerAndHeaderExcluded': True}
