#!/usr/bin/env python3
import contextlib
import json
import pathlib
import socket
import subprocess
import tempfile
import time
import urllib.request
import websocket

ROOT = pathlib.Path(__file__).resolve().parents[1]
SHIM = (ROOT / 'compat-shim.js').read_text(encoding='utf-8')
MOCK = (ROOT / 'mocks' / 'telemetry.js').read_text(encoding='utf-8')


def free_port():
    with socket.socket() as sock:
        sock.bind(('127.0.0.1', 0))
        return sock.getsockname()[1]


class CDP:
    def __init__(self, ws_url):
        self.ws = websocket.create_connection(ws_url, timeout=60, origin='http://localhost')
        self.next_id = 1

    def call(self, method, params=None, timeout=30):
        ident = self.next_id
        self.next_id += 1
        self.ws.send(json.dumps({'id': ident, 'method': method, 'params': params or {}}))
        deadline = time.time() + timeout
        while time.time() < deadline:
            message = json.loads(self.ws.recv())
            if message.get('id') == ident:
                if 'error' in message:
                    raise RuntimeError(message['error'])
                return message.get('result', {})
        raise TimeoutError(method)

    def eval(self, expression, timeout=30):
        response = self.call('Runtime.evaluate', {
            'expression': expression,
            'returnByValue': True,
            'awaitPromise': True
        }, timeout=timeout)
        if response.get('exceptionDetails'):
            raise RuntimeError(response['exceptionDetails'])
        result = response.get('result', {})
        if result.get('subtype') == 'error':
            raise RuntimeError(result.get('description'))
        return result.get('value')

    def close(self):
        self.ws.close()


def get_page(debug_port):
    endpoint = f'http://127.0.0.1:{debug_port}/json/list'
    for _ in range(200):
        try:
            with urllib.request.urlopen(endpoint, timeout=1) as response:
                targets = json.load(response)
            page = next((target for target in targets if target.get('type') == 'page'), None)
            if page:
                return page
        except Exception:
            pass
        time.sleep(0.1)
    raise RuntimeError('DevTools page unavailable')


def main():
    debug_port = free_port()
    with tempfile.TemporaryDirectory(prefix='grax151-compat-') as profile:
        command = [
            'chromium', '--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
            '--no-default-browser-check', '--disable-background-networking', '--no-proxy-server',
            '--remote-allow-origins=*', f'--remote-debugging-port={debug_port}',
            f'--user-data-dir={profile}', 'about:blank'
        ]
        process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        cdp = None
        try:
            page = get_page(debug_port)
            cdp = CDP(page['webSocketDebuggerUrl'])
            cdp.call('Runtime.enable')
            cdp.call('Page.enable')
            prelude = '''(() => {
              window.__compatErrors = [];
              window.__realSentry = { captureException() { return 'real'; } };
              window.Sentry = window.__realSentry;
              window.addEventListener('error', event => {
                window.__compatErrors.push(String(event.error?.message || event.message || 'error'));
              });
              window.addEventListener('unhandledrejection', event => {
                window.__compatErrors.push(String(event.reason?.message || event.reason || 'rejection'));
              });
              window.__nativeSetAttribute = Element.prototype.setAttribute;
              window.__nativeFunctionToString = Function.prototype.toString;
            })();'''
            cdp.call('Page.addScriptToEvaluateOnNewDocument', {'source': prelude})
            cdp.call('Page.addScriptToEvaluateOnNewDocument', {'source': SHIM})
            cdp.call('Page.reload', {'ignoreCache': True}, timeout=60)
            deadline = time.time() + 20
            while time.time() < deadline:
                if cdp.eval("document.readyState === 'complete'"):
                    break
                time.sleep(0.1)

            result = cdp.eval(r'''(() => {
              const lazyBeforeMock = typeof window.mixpanel === 'undefined';
              document.dispatchEvent(new CustomEvent('__grax_guard_state_v1__', {detail:{enabled:true}}));
              const lazyAfterEnable = typeof window.mixpanel === 'undefined';
              window.dispatchEvent(new CustomEvent('__grax_telemetry_mock_ready_v1__'));
              let callbackRan = false;
              let chainType = 'error';
              let postCallType = 'error';
              try {
                chainType = typeof window.mixpanel.analytics.identity.init('token').people.set({plan:'pro'}).track('ready');
                postCallType = typeof window.analytics.user().traits().identify().deep.next();
                window.Sentry.withScope?.(() => { callbackRan = true; });
              } catch (error) {
                window.__compatErrors.push(String(error?.message || error));
              }
              const snapshot = {
                lazyBeforeMock,
                lazyAfterEnable,
                chainType,
                postCallType,
                callbackRan,
                realSentryPreserved: window.Sentry === window.__realSentry,
                nativePrototypePreserved: Element.prototype.setAttribute === window.__nativeSetAttribute,
                gaType: typeof window.ga,
                fbqType: typeof window.fbq,
                mixpanelType: typeof window.mixpanel,
                nativeSignature: Function.prototype.toString.call(window.mixpanel),
                directSignature: window.mixpanel.toString(),
                deepDescriptor: (() => {
                  const value = window.mixpanel.analytics.identity;
                  const descriptor = Object.getOwnPropertyDescriptor(window.mixpanel.analytics, 'identity');
                  return {
                    type: typeof value,
                    configurable: descriptor?.configurable,
                    enumerable: descriptor?.enumerable,
                    writable: descriptor?.writable,
                    valueType: typeof descriptor?.value
                  };
                })(),
                nativeArraySignature: Function.prototype.toString.call(Array.prototype.push),
                errorsBeforePause: [...window.__compatErrors]
              };
              document.dispatchEvent(new CustomEvent('__grax_guard_state_v1__', {detail:{enabled:false}}));
              snapshot.stubsRemovedOnPause = typeof window.mixpanel === 'undefined' && typeof window.ga === 'undefined';
              snapshot.realSentryAfterPause = window.Sentry === window.__realSentry;
              snapshot.toStringRestored = Function.prototype.toString === window.__nativeFunctionToString;
              return snapshot;
            })()''')

            mock_result = cdp.eval(f'''(() => {{
              try {{ delete window.mixpanel; delete window.analytics; delete window.ga; delete window.fbq; }} catch {{}}
              let ready = 0;
              window.addEventListener('__grax_telemetry_mock_ready_v1__', () => ready++, {{once:true}});
              {MOCK}
              let deep = 'error';
              try {{ deep = typeof window.datadogRum.session.identity.init().track().nested(); }} catch (error) {{ window.__compatErrors.push(String(error?.message || error)); }}
              return {{ ready, deep, errors: [...window.__compatErrors] }};
            }})()''')

            if not result['lazyBeforeMock'] or not result['lazyAfterEnable']:
                raise AssertionError('compatibility shim installed eagerly before a confirmed mock\n' + json.dumps(result, indent=2))
            if result['chainType'] != 'function' or result['postCallType'] != 'function':
                raise AssertionError('recursive proxy chain failed\n' + json.dumps(result, indent=2))
            if not result['realSentryPreserved'] or not result['realSentryAfterPause']:
                raise AssertionError('real SDK object was overwritten\n' + json.dumps(result, indent=2))
            if not result['nativePrototypePreserved']:
                raise AssertionError('Element prototype was mutated\n' + json.dumps(result, indent=2))
            if result['gaType'] != 'function' or result['fbqType'] != 'function' or result['mixpanelType'] != 'function':
                raise AssertionError('expected callable shims missing\n' + json.dumps(result, indent=2))
            if result.get('nativeSignature') != 'function () { [native code] }' or result.get('directSignature') != 'function () { [native code] }':
                raise AssertionError('native signature masking failed\n' + json.dumps(result, indent=2))
            descriptor = result.get('deepDescriptor') or {}
            if descriptor.get('type') != 'function' or descriptor.get('valueType') != 'function' or descriptor.get('configurable') is not True or descriptor.get('enumerable') is not False or descriptor.get('writable') is not True:
                raise AssertionError('deep proxy descriptor mirroring failed\n' + json.dumps(result, indent=2))
            if '[native code]' not in result.get('nativeArraySignature', ''):
                raise AssertionError('native function signatures were altered\n' + json.dumps(result, indent=2))
            if not result['stubsRemovedOnPause'] or not result.get('toStringRestored'):
                raise AssertionError('pause cleanup failed\n' + json.dumps(result, indent=2))
            if result['errorsBeforePause']:
                raise AssertionError('runtime errors before pause\n' + json.dumps(result, indent=2))
            if mock_result['ready'] != 1 or mock_result['deep'] != 'function' or mock_result['errors']:
                raise AssertionError('mock response shim failed\n' + json.dumps(mock_result, indent=2))

            print(json.dumps({
                'chromium': cdp.call('Browser.getVersion').get('product'),
                'compatibilityShim': result,
                'mockResponse': mock_result
            }, indent=2))
        finally:
            if cdp:
                with contextlib.suppress(Exception):
                    cdp.close()
            process.terminate()
            with contextlib.suppress(Exception):
                process.wait(timeout=5)
            if process.poll() is None:
                process.kill()


if __name__ == '__main__':
    main()
