#!/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]
GUARD = (ROOT / 'page-guard.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:
            msg = json.loads(self.ws.recv())
            if msg.get('id') == ident:
                if 'error' in msg:
                    raise RuntimeError(msg['error'])
                return msg.get('result', {})
        raise TimeoutError(method)

    def eval(self, expression, user_gesture=False, timeout=30):
        response = self.call('Runtime.evaluate', {
            'expression': expression,
            'returnByValue': True,
            'awaitPromise': True,
            'userGesture': user_gesture,
        }, 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((t for t in targets if t.get('type') == 'page'), None)
            if page:
                return page
        except Exception:
            pass
        time.sleep(0.1)
    raise RuntimeError('DevTools page unavailable')


def run_context(referrer, expression):
    debug_port = free_port()
    with tempfile.TemporaryDirectory(prefix='grax148-waf-') as profile:
        cmd = [
            'chromium', '--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
            '--no-default-browser-check', '--disable-background-networking', '--no-proxy-server',
            '--remote-allow-origins=*', '--autoplay-policy=no-user-gesture-required', '--mute-audio',
            f'--remote-debugging-port={debug_port}', f'--user-data-dir={profile}', 'about:blank'
        ]
        proc = subprocess.Popen(cmd, 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 = f'''(() => {{
              try {{ Object.defineProperty(document, 'referrer', {{ configurable: true, value: {json.dumps(referrer)} }}); }} catch {{}}
              window.__nativeOpenCalls = [];
              window.__nativeOpenSpy = function nativeOpenSpy(url, target, features) {{
                window.__nativeOpenCalls.push([String(url ?? ''), String(target ?? ''), String(features ?? '')]);
                return {{ native: true, opener: window, location: {{ href: String(url || 'about:blank') }}, close() {{}}, focus() {{}} }};
              }};
              try {{ Object.defineProperty(window, 'open', {{ configurable: true, enumerable: true, writable: true, value: window.__nativeOpenSpy }}); }} catch {{ window.open = window.__nativeOpenSpy; }}
            }})();'''
            cdp.call('Page.addScriptToEvaluateOnNewDocument', {'source': prelude})
            cdp.call('Page.addScriptToEvaluateOnNewDocument', {'source': GUARD})
            cdp.call('Page.reload', {'ignoreCache': True}, timeout=60)
            deadline = time.time() + 20
            while time.time() < deadline:
                try:
                    if cdp.eval("document.readyState === 'complete' && Boolean(globalThis.__graxPageGuardInstalled)"):
                        break
                except Exception:
                    pass
                time.sleep(0.1)
            else:
                raise RuntimeError('Guard did not initialize')
            return cdp.eval(expression, user_gesture=True, timeout=60)
        finally:
            if cdp:
                with contextlib.suppress(Exception):
                    cdp.close()
            proc.terminate()
            with contextlib.suppress(subprocess.TimeoutExpired):
                proc.wait(timeout=5)
            if proc.poll() is None:
                proc.kill()


def assert_true(result, keys, label):
    failures = [key for key in keys if result.get(key) is not True]
    if failures:
        raise AssertionError(f'{label}: {", ".join(failures)}\n{json.dumps(result, indent=2)}')


def main():
    challenge_document = run_context(
        'https://filma24.stream/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1?__cf_chl_f_tk=grax148',
        r'''(async()=>{
          window.__errors=[];
          addEventListener('error',e=>__errors.push(String(e.error||e.message)));
          addEventListener('unhandledrejection',e=>__errors.push(String(e.reason)));

          const host=document.createElement('div');
          document.body.appendChild(host);
          const closedRoot=host.attachShadow({mode:'closed'});

          const challengeMarkup=document.createElement('div');
          challengeMarkup.innerHTML='<iframe id="cf-frame" hidden src="https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/turnstile/if/ov2/av0/rcv0/0x4AAAA"></iframe>';
          document.body.appendChild(challengeMarkup);
          const challengeFrame=challengeMarkup.querySelector('iframe');

          let workerResult=null;
          const blob=new Blob(['postMessage("challenge-worker-ok")'],{type:'text/javascript'});
          const workerUrl=URL.createObjectURL(blob);
          const worker=new Worker(workerUrl);
          worker.onmessage=e=>{workerResult=e.data;worker.terminate();URL.revokeObjectURL(workerUrl);};
          await new Promise(r=>setTimeout(r,350));

          const adResult=window.open('https://rcdn-web.com/ad?clickid=148','_blank');
          const challengeResult=window.open('https://challenges.cloudflare.com/turnstile/v0/g/abc','_blank');
          const adAnchor=document.createElement('a');
          adAnchor.href='https://rcdn-web.com/landing?zoneid=148';
          adAnchor.target='_blank';
          const adAnchorResult=adAnchor.click();

          return {
            installed:Boolean(globalThis.__graxPageGuardInstalled),
            windowOpenGuarded:window.open!==window.__nativeOpenSpy,
            openFrozen:Object.getOwnPropertyDescriptor(window,'open')?.configurable===false && Object.getOwnPropertyDescriptor(window,'open')?.writable===false,
            closedShadowPreserved:closedRoot.mode==='closed',
            challengeMarkupPreserved:challengeFrame?.getAttribute('src')?.includes('challenges.cloudflare.com') && !challengeFrame?.hasAttribute('sandbox'),
            workerResult,
            adWindowInert:Boolean(adResult && adResult.location?.href==='about:blank'),
            challengeWindowNative:Boolean(challengeResult?.native),
            adAnchorBlocked:adAnchorResult===false,
            nativeOpenCalls:window.__nativeOpenCalls,
            errors:window.__errors
          };
        })()'''
    )
    assert_true(challenge_document, [
        'installed', 'windowOpenGuarded', 'openFrozen', 'closedShadowPreserved',
        'challengeMarkupPreserved', 'adWindowInert', 'challengeWindowNative', 'adAnchorBlocked'
    ], 'challenge document')
    if challenge_document.get('workerResult') != 'challenge-worker-ok':
        raise AssertionError('challenge document worker failed\n' + json.dumps(challenge_document, indent=2))
    if challenge_document.get('errors'):
        raise AssertionError('challenge document errors\n' + json.dumps(challenge_document, indent=2))
    calls = challenge_document.get('nativeOpenCalls', [])
    if len(calls) != 1 or 'challenges.cloudflare.com' not in calls[0][0]:
        raise AssertionError('challenge destination isolation failed\n' + json.dumps(challenge_document, indent=2))

    embedded_challenge = run_context(
        'https://filma24.stream/movie',
        r'''(async()=>{
          window.__errors=[];
          addEventListener('error',e=>__errors.push(String(e.error||e.message)));
          addEventListener('unhandledrejection',e=>__errors.push(String(e.reason)));

          const challengeScript=document.createElement('script');
          challengeScript.src='/cdn-cgi/challenge-platform/scripts/jsd/api.js?__cf_chl_f_tk=grax148';

          const challengeFrame=document.createElement('iframe');
          challengeFrame.id='cf-chl-widget-grax';
          challengeFrame.hidden=true;
          challengeFrame.src='https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/turnstile/if/ov2/av0/0x4AAAA';
          document.body.appendChild(challengeFrame);

          const host=document.createElement('div');
          document.body.appendChild(host);
          const closedRoot=host.attachShadow({mode:'closed'});

          let workerResult=null;
          const blob=new Blob(['postMessage("embedded-waf-worker-ok")'],{type:'text/javascript'});
          const workerUrl=URL.createObjectURL(blob);
          const worker=new Worker(workerUrl);
          worker.onmessage=e=>{workerResult=e.data;worker.terminate();URL.revokeObjectURL(workerUrl);};

          const mixed=document.createElement('div');
          mixed.innerHTML='<iframe id="verified" hidden src="https://challenges.cloudflare.com/turnstile/v0/g/abc"></iframe><iframe id="adframe" hidden src="https://rcdn-web.com/popunder?zoneid=148"></iframe>';
          document.body.appendChild(mixed);
          await new Promise(r=>setTimeout(r,350));

          const verified=mixed.querySelector('#verified');
          const adframe=mixed.querySelector('#adframe');
          const adResult=window.open('https://rcdn-web.com/ad?clickid=148','_blank');
          const adAnchor=document.createElement('a');
          adAnchor.href='https://rcdn-web.com/landing?clickid=148';
          adAnchor.target='_blank';
          const adAnchorResult=adAnchor.click();

          return {
            installed:Boolean(globalThis.__graxPageGuardInstalled),
            openFrozen:Object.getOwnPropertyDescriptor(window,'open')?.configurable===false && Object.getOwnPropertyDescriptor(window,'open')?.writable===false,
            closedShadowPreserved:closedRoot.mode==='closed',
            workerResult,
            verifiedFramePreserved:verified?.getAttribute('src')?.includes('challenges.cloudflare.com') && !verified?.hasAttribute('sandbox'),
            adFrameContained:adframe?.getAttribute('src')==='about:blank' || adframe?.hasAttribute('sandbox'),
            adWindowInert:Boolean(adResult && adResult.location?.href==='about:blank'),
            adAnchorBlocked:adAnchorResult===false,
            noNativeAdOpen:window.__nativeOpenCalls.length===0,
            errors:window.__errors
          };
        })()'''
    )
    assert_true(embedded_challenge, [
        'installed', 'openFrozen', 'closedShadowPreserved', 'verifiedFramePreserved',
        'adFrameContained', 'adWindowInert', 'adAnchorBlocked', 'noNativeAdOpen'
    ], 'embedded challenge')
    if embedded_challenge.get('workerResult') != 'embedded-waf-worker-ok':
        raise AssertionError('embedded challenge worker failed\n' + json.dumps(embedded_challenge, indent=2))
    if embedded_challenge.get('errors'):
        raise AssertionError('embedded challenge errors\n' + json.dumps(embedded_challenge, indent=2))

    print(json.dumps({
        'chromium': subprocess.check_output(['chromium', '--version'], text=True).strip(),
        'challengeDocument': challenge_document,
        'embeddedChallengeOnAggressiveOrigin': embedded_challenge
    }, indent=2))


if __name__ == '__main__':
    main()
