import difflib
from functools import reduce
import os
import re
import uuid
from pathlib import Path
import shutil
import traceback as tb
import collections.abc
import asyncio
from typing import Literal, Sequence, TypedDict, cast

from playwright.async_api import async_playwright, Page, Frame
from subprocess import run
import reactivex as rx
from reactivex.abc.disposable import DisposableBase

from yaml import full_load as yamlparse, dump
from urllib.parse import ParseResult, urlparse, parse_qs, urlencode, urlunparse
from caserunner.websocket import ContextMenuEvent, WebsocketService, InboundWebsocketMessage, ClickEvent, ServerConnection, InputEvent, RecordingEvent

import logging
from datetime import datetime, timezone
from platformdirs import user_log_path, user_data_path

APP_NAME = "CaseRunner Recorder"
APP_AUTHOR = "Bytemethod"

now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S-%f")[:-3]+"Z"
log_dir = user_log_path(APP_NAME, APP_AUTHOR, ensure_exists=True)
# keep only last 25 log files
logs_to_keep = 25
log_files = sorted(log_dir.glob("*.log"))
if len(log_files) > logs_to_keep:
    for file in log_files[:-logs_to_keep]:
        #print(f"Removing old log file: {str(file)}")
        file.unlink()

log_file = str(log_dir / f"{now}.log")
print(f"Saving logs to {log_file}")
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)

logging.basicConfig(handlers=[file_handler, console_handler], level=logging.DEBUG, format='%(asctime)-25s %(levelname)-8s [%(name)s] %(message)s')
logging.getLogger("websockets.server").setLevel(logging.INFO)
class HandshakeErrorFilter(logging.Filter):
    def filter(self, record):
        if "opening handshake failed" in record.getMessage():
            print("Intercepted connection error log")
            return False
        return True
handshake_error_filter = HandshakeErrorFilter()
logging.getLogger("websockets.server").addFilter(handshake_error_filter)

logger = logging.getLogger("main")


class NavigatedInteraction(TypedDict):
    type: Literal["page-navigated"]
    url: str


class ClickInteraction(TypedDict):
    type: Literal["user-click"]
    elementTree: str


class InputInteraction(TypedDict):
    type: Literal["user-input"]
    elementTree: str


class ContextMenuInteraction(TypedDict):
    type: Literal["user-context-menu"]
    elementTree: str


Interaction = NavigatedInteraction | ClickInteraction | InputInteraction | ContextMenuInteraction


class Undefined(collections.abc.Mapping):
    def __bool__(self):
        return False

    def __iter__(self):
        return iter({})

    def __getitem__(self, item):
        return None

    def __len__(self):
        return 0


class UrlMapRecord(TypedDict):
    pageNo: int
    snapshots: int
    interaction_timeout: int


class Recorder:
    def __init__(self, start_url: str = "app.development.hirelogic.com", output_path: str = "output", browser_path: str | None = None, browser_cdp: str | None = None, format: Literal["full", "aidiff", "sdiff"] = "full", development: bool = False):
        self.recording = True
        self.interactionDisabled = False

        self.start_url = start_url
        self.output_path = output_path
        self.browser_path = browser_path
        self.browser_cdp = browser_cdp
        self.development = development

        self.page_map: dict[str, Page] = {}
        self.waitForLoadStateAbort = rx.Subject[None]()
        # dictionary of visited urls with their pageNo and number of snapshots for each url
        self.url_map: dict[str, UrlMapRecord] = dict()

        self.interactions: list[Interaction] = []
        self.previousPageUrl: str | None = None
        self.currentPageUrl: str | None = None

        self.snapshotVariant: Literal["full", "aidiff", "sdiff"] = format
        self._script_dir = os.path.dirname(os.path.abspath(__file__))
        logger.debug(f"Recorder created: start_url: {self.start_url}, output_path: {self.output_path}, browser_path: {self.browser_path}, browser_cdp: {self.browser_cdp}, development: {self.development}, format: {self.snapshotVariant}")

    async def wait_for_all_frames(self, page: Page, event: Literal["domcontentloaded", "networkidle"]):
        url_record = self.url_map.get(self.currentPageUrl or "")
        timeout = url_record["interaction_timeout"] if url_record else 15000

        async def wait_for_frame(frame: Frame):
            try:
                await frame.wait_for_load_state(event, timeout=timeout)
            except Exception:
                self.url_map[self.currentPageUrl or ""]["interaction_timeout"] = 2000
            await asyncio.gather(*map(wait_for_frame, frame.child_frames or []))

        await wait_for_frame(page.main_frame)

    async def run(self):
        print("==================================")
        print("Close browser to end recording")
        print("==================================")
        logger.debug("Recorder started")
        if self.development:
            run("npm install", check=True, cwd=(
                Path(self._script_dir)/"inject").absolute(), shell=True)

            # command = ['esbuild', 'injected/injectedScript.ts',
            #            '--bundle', '--outfile=injected/injectedScript.js']
            # command = ['esbuild', 'inject/src/index.ts', '--bundle',
            #           '--outfile=inject/inject-bundle.js', '--format=iife', '--minify']
            run("npm run build", check=True, cwd=(
                Path(self._script_dir)/"inject").absolute(), shell=True)

        output = Path(self.output_path)
        if output.exists() and output.is_dir():
            shutil.rmtree(output)

        async with WebsocketService() as ws, async_playwright() as playwright:
            if self.browser_path:
                if self.browser_path.endswith("firefox.exe") or self.browser_path.lower().find("mozilla") != -1:
                    browser = await playwright.firefox.launch(
                        executable_path=self.browser_path,
                        headless=False,
                        args=[
                            "--disable-web-security",
                            '--no-sandbox',
                            '--disable-setuid-sandbox',
                        ])
                elif self.browser_path.lower().find("safari") != -1:
                    browser = await playwright.webkit.launch(
                        executable_path=self.browser_path,
                        headless=False,
                        args=[
                            "--disable-web-security",
                            '--no-sandbox',
                            '--disable-setuid-sandbox',
                        ])
                else:
                    browser = await playwright.chromium.launch(
                        executable_path=self.browser_path,
                        headless=False,
                        args=[
                            "--disable-web-security",
                            '--no-sandbox',
                            '--disable-setuid-sandbox',
                        ])
                context = await browser.new_context(no_viewport=True, bypass_csp=True)
            elif self.browser_cdp:
                browser = await playwright.chromium.connect_over_cdp(endpoint_url=f"http://localhost:{self.browser_cdp}")
                context = browser.contexts[0]
            else:
                context = await playwright.chromium.launch_persistent_context(
                    user_data_path(APP_NAME, APP_AUTHOR, ensure_exists=True)/"browser-profile",
                    headless=False,
                    no_viewport=True,
                    bypass_csp=True,
                    args=[
                        "--disable-web-security",
                        '--no-sandbox',
                        '--disable-setuid-sandbox',
                    ])

            logger.debug("Browser launched")
            browser_closed = asyncio.Future()
            context.on("close", lambda _: browser_closed.set_result(None))
            for page in context.pages:
                await self._handlePageOpen(page, ws)
            context.on("page", lambda page: self._handlePageOpen(page, ws))
            logger.debug("Browser listeners added")

            ws.addListener(lambda *event: self._handleClickEvent(ws, *event))
            ws.addListener(lambda *event: self._handleInputEvent(ws, *event))
            ws.addListener(
                lambda *event: self._handleRecordingEvent(ws, *event))
            ws.addListener(
                lambda *event: self._handleContextMenuEvent(ws, *event))
            logger.debug("WebSocket listeners added")

            parse_result = urlparse(self.start_url)._asdict()
            parse_result["scheme"] = parse_result["scheme"] or "https"
            await list(self.page_map.values())[0].goto(url=urlunparse(ParseResult(**parse_result)))
            await asyncio.ensure_future(browser_closed)

        if output.exists() and output.is_dir():
            import datetime
            t = datetime.datetime.now()
            shutil.make_archive(str(output) + t.strftime('-%Y%m%d-%H-%M'), 'zip', str(self.output_path))
            # shutil.rmtree(output)
            print("==================================")
            print(f"Recording saved to {output}.zip")
            print("==================================")

    async def _handlePageOpen(self, page: Page, ws: WebsocketService):
        page_uuid = str(uuid.uuid4())
        logger.debug(f'New page opened: "{page.url}" with uuid {page_uuid}')

        page.on("close", lambda _: self._handlePageClosed(page_uuid))
        page.on("framenavigated",
                lambda *event: self._handleFrameNavigated(ws, page, *event))

        self.page_map[page_uuid] = page

        with open(os.path.join(self._script_dir, "inject/inject-bundle.js"), encoding="utf-8", mode="r") as file:
            injected_script = file.read()
            injected_script = re.sub(
                r'THIS_WILL_BE_REPLACED_WITH_THE_ACTUAL_WS_URL', f'ws://localhost:{ws.port}', injected_script)
            injected_script = re.sub(
                r'THIS_WILL_BE_REPLACED_WITH_THE_ACTUAL_PAGE_ID', page_uuid, injected_script)
            await page.add_init_script(injected_script)
        logger.debug("Injection script added to page")

    async def _handlePageClosed(self, uuid: str):
        logger.debug(f"Page with uuid {uuid} closed.")
        if not uuid in self.page_map:
            logger.warning(
                f"Page with uuid {uuid} is not registered in page map")
        else:
            self.page_map.pop(uuid)

    async def _processPage(self, page: Page, ws: WebsocketService):
        if not self.currentPageUrl:
            return
        if not self.recording:
            logger.debug("Not recording, skipping snapshot")
            return
        # page.locator("body").aria_snapshot()
        shouldComplete = True
        abortSubscribe: DisposableBase

        def abort(_) -> None:
            nonlocal shouldComplete
            abortSubscribe.dispose()
            shouldComplete = False
        abortSubscribe = self.waitForLoadStateAbort.subscribe(abort)

        snapshot = await self.take_snapshot(page, ws)
        if snapshot is None:
            logger.warning("Snapshot timed out")
            return
        logger.debug(f"Snapshot taken")
        try:
            pageTitle = await page.title()
        except Exception as e:
            logger.warning("Could not get page title")
            logger.info(e)
            pageTitle = "Unknown title"

        # print("Snapshot taken:", filteredSnapshot)
        snapshotString = dump(snapshot, allow_unicode=True)

        # resolve pageNo
        pageRecord = self.url_map.get(self.currentPageUrl)
        if pageRecord:
            pageNo = pageRecord["pageNo"]
        else:
            pageNo = len(self.url_map.keys()) + 1
            self.url_map[self.currentPageUrl] = {
                "pageNo": pageNo, "snapshots": 0, "interaction_timeout": 15000}

        snapshotNo = self.url_map[self.currentPageUrl]["snapshots"] + 1

        if shouldComplete:
            logger.debug(f"Saving screenshot {snapshotNo} of page {pageNo} ({self.currentPageUrl})")
            os.makedirs(
                f"{self.output_path}/page-{pageNo:03}", exist_ok=True)

            screenshot_path = f"./{self.output_path}/page-{pageNo:03}/snapshot-{snapshotNo:03}.png"
            screenshot_style = """
                #caserunner-disable-interaction {
                    opacity: 0 !important;
                }
                #caserunner-controls {
                    opacity: 0 !important;
                }
            """

            if self.snapshotVariant == "full":
                # compare snapshot with all previous snapshots of this page saved on disk
                try:
                    for file in Path(f"{self.output_path}/page-{pageNo:03}").glob("snapshot-*.yaml"):
                        with open(file, encoding="utf-8", mode="r") as f:
                            parsedFile = yamlparse(f.read())
                            logger.debug(
                                len(dump(parsedFile["snapshot"], allow_unicode=True)))
                            if dump(parsedFile["snapshot"], allow_unicode=True) == snapshotString:
                                return  # skip if snapshot is the same as previous one
                except Exception as e:
                    logger.warning("Could not compare with previous snapshots")
                    logger.warning(e)

                if shouldComplete:
                    with open(f"{self.output_path}/page-{pageNo:03}/snapshot-{snapshotNo:03}.yaml", encoding="utf-8", mode="w") as file:
                        file.write(dump({
                            "currentPageUrl": self.currentPageUrl,
                            "pageTitle": pageTitle,
                            "snapshot": snapshot,
                            "interactions": list(map(self._formatInteraction, self.interactions)),
                        }, allow_unicode=True))
                    await page.screenshot(
                        path=screenshot_path,
                        # animations = "disabled",
                        style=screenshot_style,
                    )
                    self.url_map[self.currentPageUrl]["snapshots"] += 1
                    # clear interactions for next snapshot
                    self.interactions = []
            elif self.snapshotVariant == "sdiff":
                snapshotPath = Path(
                    f"{self.output_path}/page-{pageNo:03}/snapshot.yaml")
                if snapshotPath.exists():
                    logger.debug("Comparing with initial snapshot")
                    snapshotContent = yamlparse(
                        snapshotPath.read_text(encoding="utf-8"))
                    initialSnapshot = dump(
                        snapshotContent["snapshot"]).splitlines()
                    filteredSnapshotLines = snapshotString.splitlines()

                    def strip_all(lines: Sequence[str]) -> list[str]:
                        return list(map(lambda line: line.strip(), lines))

                    differ = difflib.SequenceMatcher(None, strip_all(
                        initialSnapshot), strip_all(filteredSnapshotLines))
                    matches = differ.get_matching_blocks()

                    result: list[tuple[int, str]] = []

                    for linenumber, line in enumerate(filteredSnapshotLines):
                        def check_line():
                            for match in matches:
                                if match.b == 0 and linenumber - 2 < 0:
                                    return False
                                if match.b + match.size == len(filteredSnapshotLines) and linenumber + 2 > len(filteredSnapshotLines) - 1:
                                    return False
                                if match.b <= linenumber - 2 and match.b + match.size > linenumber + 2:
                                    return False
                            return True
                        if check_line():
                            result.append((linenumber, line))

                    def line_reducer(acc: list[tuple[int, int, str]], line: tuple[int, str]):
                        if not acc:
                            acc.append((line[0], line[0], line[1]))
                        else:
                            if acc[-1][1] + 1 == line[0]:
                                acc[-1] = acc[-1][0], line[0], acc[-1][2] + \
                                    "\n" + line[1]
                            else:
                                acc.append((line[0], line[0], line[1]))
                        return acc
                    continuous_chunks = reduce(line_reducer, result, [])
                    logger.debug(f"Found {len(continuous_chunks)} changed chunks")

                    delta = []

                    for chunk in continuous_chunks:
                        if len(chunk[2].splitlines()) > 1 << 8:
                            continue
                        delta.append({
                            "span": f"({chunk[0]}, {chunk[1]})",
                            "diff": chunk[2]})

                    delta_string = dump(delta)
                    for previous_deltas in snapshotContent["snapshotDifferences"]:
                        if dump(previous_deltas["changedChunks"]) == delta_string:
                            return

                    if shouldComplete and delta:
                        snapshotContent["snapshotDifferences"] += [{
                            "changedChunks": delta,
                            "interactions": list(map(self._formatInteraction, self.interactions))
                        }]
                        snapshotPath.write_text(
                            dump(snapshotContent, allow_unicode=True), encoding="utf-8")
                        await page.screenshot(
                            path=screenshot_path,
                            # animations = "disabled",
                            style=screenshot_style,
                        )
                        self.url_map[self.currentPageUrl]["snapshots"] += 1
                        # clear interactions for next snapshot
                        self.interactions = []
                else:
                    logger.debug("Saving initial snapshot")
                    if shouldComplete:
                        snapshotPath.write_text(
                            dump({
                                "currentPageUrl": self.currentPageUrl,
                                "pageTitle": pageTitle,
                                "interactions": list(map(self._formatInteraction, self.interactions)),
                                "snapshot": snapshot,
                                "snapshotDifferences": [],
                            }, allow_unicode=True), encoding="utf-8"
                        )
                        await page.screenshot(
                            path=screenshot_path,
                            # animations = "disabled",
                            style=screenshot_style,
                        )
                        self.url_map[self.currentPageUrl]["snapshots"] += 1
                        # clear interactions for next snapshot
                        self.interactions = []

        abortSubscribe.dispose()

    def _formatInteraction(self, interaction: Interaction) -> dict:
        if interaction["type"] == "page-navigated":
            return {
                "type": "page-navigated",
                "previousPageUrl": interaction["url"],
            }
        elif interaction["type"] == "user-click":
            try:
                element = yamlparse(interaction["elementTree"])
                return {
                    "type": "user-click",
                    "clickedElement": element if element else None
                }
            except Exception as e:
                logger.warning("Could not process clicked element interaction")
                logger.warning(e)
                return {
                    "type": "user-click",
                    "clickedElement": interaction["elementTree"]
                }
        elif interaction["type"] == "user-input":
            try:
                element = yamlparse(interaction["elementTree"])
                return {
                    "type": "user-input",
                    "inputElement": element if element else None
                }
            except Exception as e:
                logger.warning("Could not process user input interaction")
                logger.warning(e)
                return {
                    "type": "user-input",
                    "inputElement": interaction["elementTree"]
                }
        elif interaction["type"] == "user-context-menu":
            return {
                "type": "user-context-menu",
                "contextElement": interaction["elementTree"]
            }

    async def take_snapshot(self, page: Page, ws: WebsocketService):
        snapshot_future = asyncio.Future()

        reverse_dictionary = {v: k for k, v in self.page_map.items()}
        page_uuid = reverse_dictionary.get(page)
        if not page_uuid:
            logger.error("Page UUID not found in page map")
            return None

        logger.debug(f"Taking snapshot of page {page_uuid}")
        id = str(uuid.uuid4())

        def snapshot_handler(message: InboundWebsocketMessage, _connection: ServerConnection):
            if message["topic"] != "snapshot":
                return
            if message["payload"]["id"] != id:
                return

            ws.removeListener(snapshot_handler)
            ariaTree = message["payload"]["ariaTree"]
            yaml = yamlparse(ariaTree)
            snapshot_future.set_result(yaml)

        ws.addListener(snapshot_handler)

        await ws.broadcast({
            "topic": "snapshot-request",
            "page_uuid": page_uuid,
            "payload": {
                "id": id,
            },
        })

        # timeout after 20 seconds, so the recorder doesn't hang forever
        rx.timer(20.0).subscribe(lambda _: snapshot_future.set_result(None) if not snapshot_future.done() else None)
        return await asyncio.ensure_future(snapshot_future)

    async def changeInteractionStatus(self, ws: WebsocketService, disabled: bool):
        self.interactionDisabled = disabled
        logger.debug(f"Sending broadcast: interactionDisabled = {self.interactionDisabled}")
        await ws.broadcast({
            "topic": "interaction",
            "payload": {"disabled": self.interactionDisabled},
        })

    async def _handleClickEvent(self, ws: WebsocketService, message: InboundWebsocketMessage, _connection: ServerConnection):
        # idk how to do it correctly, so whatever
        if message["topic"] != 'clickEvent':
            return
        typed_message: ClickEvent = cast(ClickEvent, message)

        page = self.page_map.get(typed_message["pageId"])
        if not page:
            logger.error(
                f"Page {typed_message['pageId']} not found in page map.")
            return

        logger.debug(f"Handling click event on page {typed_message['pageId']}")
        await self.changeInteractionStatus(ws, True)
        self.interactions.append({
            "type": "user-click",
            "elementTree": typed_message["elementTree"],
        })

        shouldComplete = True
        abortSubscribe: DisposableBase

        def abort(_) -> None:
            nonlocal shouldComplete
            abortSubscribe.dispose()
            shouldComplete = False
        abortSubscribe = self.waitForLoadStateAbort.subscribe(abort)

        logger.debug(f"Waiting for changes in page after click - {typed_message['pageId']}")
        try:
            await self.wait_for_all_frames(page, "domcontentloaded")
        except:
            logger.warning("Wait for stable document timed out")
        await asyncio.sleep(1)

        try:
            if shouldComplete:
                await self._processPage(page, ws)
        except Exception as e:
            logger.error(f"Error processing page after click: {e}")
            logger.error(tb.format_exc())
        finally:
            abortSubscribe.dispose()
            await self.changeInteractionStatus(ws, False)

    async def _handleInputEvent(self, ws: WebsocketService, message: InboundWebsocketMessage, _connection: ServerConnection):
        # idk how to do it correctly, so whatever
        if message["topic"] != 'inputEvent':
            return
        typed_message: InputEvent = cast(InputEvent, message)

        page = self.page_map.get(typed_message["pageId"])
        if not page:
            logger.error(
                f"Page {typed_message['pageId']} not found in page map.")
            return

        logger.debug(f"Handling input event on page {typed_message['pageId']}")
        await self.changeInteractionStatus(ws, True)
        self.interactions.append({
            "type": "user-input",
            "elementTree": typed_message["elementTree"],
        })

        shouldComplete = True
        abortSubscribe: DisposableBase

        def abort(_) -> None:
            nonlocal shouldComplete
            abortSubscribe.dispose()
            shouldComplete = False
        abortSubscribe = self.waitForLoadStateAbort.subscribe(abort)

        logger.debug(f"Waiting for changes in page after input - {typed_message['pageId']}")
        try:
            await self.wait_for_all_frames(page, "domcontentloaded")
        except:
            logger.warning("Wait for stable document timed out")
        await asyncio.sleep(1)

        try:
            if shouldComplete:
                await self._processPage(page, ws)
        except Exception as e:
            logger.error(f"Error processing page after input: {e}")
            logger.error(tb.format_exc())
        finally:
            abortSubscribe.dispose()
            await self.changeInteractionStatus(ws, False)

    async def _handleKeyEvent(self, ws: WebsocketService, message: InboundWebsocketMessage, _connection: ServerConnection):
        if message["topic"] != 'clickEvent':
            return
        typed_message: ClickEvent = cast(ClickEvent, message)

        self.interactions.append({
            "type": "user-click",
            "elementTree": typed_message["elementTree"],
        })

    async def _handleContextMenuEvent(self, ws: WebsocketService, message: InboundWebsocketMessage, _connection: ServerConnection):
        if message["topic"] != 'contextMenuEvent':
            return
        typed_message: ContextMenuEvent = cast(ContextMenuEvent, message)

        page = self.page_map.get(typed_message["pageId"])
        if not page:
            logger.error(
                f"Page {typed_message['pageId']} not found in page map.")
            return

        logger.debug(f"Handling context menu event on page {typed_message['pageId']}")
        await self.changeInteractionStatus(ws, True)
        self.interactions.append({
            "type": "user-context-menu",
            "elementTree": typed_message["elementTree"],
        })

        shouldComplete = True
        abortSubscribe: DisposableBase

        def abort(_) -> None:
            nonlocal shouldComplete
            abortSubscribe.dispose()
            shouldComplete = False
        abortSubscribe = self.waitForLoadStateAbort.subscribe(abort)

        logger.debug(f"Waiting for changes in page after context menu/right click - {typed_message['pageId']}")
        try:
            await self.wait_for_all_frames(page, "domcontentloaded")
        except:
            logger.warning("Wait for stable document timed out")
        await asyncio.sleep(1)

        try:
            if shouldComplete:
                await self._processPage(page, ws)
        except Exception as e:
            abortSubscribe.dispose()
            logger.error(f"Error processing page after context menu/right click: {e}")
            logger.error(tb.format_exc())
        finally:
            await self.changeInteractionStatus(ws, False)

    async def _handleRecordingEvent(self, ws: WebsocketService, message: InboundWebsocketMessage, _connection: ServerConnection):
        if message["topic"] == 'recording':
            typed_message: RecordingEvent = cast(RecordingEvent, message)
            self.recording = typed_message["payload"]["running"]
            logger.debug(f"Recording state changed, broadcasting running: {self.recording}")

            await ws.broadcast({
                "topic": "recording",
                "payload": {"running": self.recording},
            })
            if self.recording:
                await self.changeInteractionStatus(ws, True)
                await asyncio.sleep(1)
                for page in self.page_map.values():
                    try:
                        await self._processPage(page, ws)
                    except Exception as e:
                        logger.error(f"Error processing page after unpausing recording: {e}")
                        logger.error(tb.format_exc())
                await self.changeInteractionStatus(ws, False)

        if message["topic"] == 'recording-status':
            logger.debug(f"Recording state request received, broadcasting running: {self.recording}")
            await ws.broadcast({
                "topic": "recording",
                "payload": {"running": self.recording},
            })

        if message["topic"] == 'interaction-status':
            logger.debug(f"Interaction state request received, broadcasting disabled: {self.interactionDisabled}")
            await ws.broadcast({
                "topic": "interaction",
                "payload": {"disabled": self.interactionDisabled},
            })

    async def _handleFrameNavigated(self, ws: WebsocketService, page: Page, frame: Frame):
        if frame.parent_frame == None and not page.url.startswith("about"):

            reverse_dictionary = {v: k for k, v in self.page_map.items()}
            page_uuid = reverse_dictionary.get(page)
            logger.debug(f"Main frame of page {page_uuid} navigated to {frame.url}")

            self.waitForLoadStateAbort.on_next(None)
            await self.changeInteractionStatus(ws, True)

            shouldComplete = True
            abortSubscribe: DisposableBase

            def abort(_) -> None:
                nonlocal shouldComplete
                abortSubscribe.dispose()
                shouldComplete = False
            abortSubscribe = self.waitForLoadStateAbort.subscribe(abort)

            logger.debug(f"Waiting for changes in page after navigation - {page_uuid}")
            try:
                await self.wait_for_all_frames(page, "domcontentloaded")
                await self.wait_for_all_frames(page, "networkidle")
            except:
                logger.warning("Wait for stable document timed out")
            await asyncio.sleep(2)
            abortSubscribe.dispose()
            if shouldComplete:
                # remove long query parameters
                url = urlparse(frame.url)
                qs = parse_qs(url.query)
                for key, value in list(qs.items()):
                    if any(map(lambda val: len(val) > 64, value)):
                        qs.pop(key)
                urldict = url._asdict()
                urldict["query"] = urlencode(qs, doseq=True)
                self.currentPageUrl = urlunparse(
                    ParseResult(**urldict))

                self.interactions.append({
                    "type": "page-navigated",
                    "url": self.previousPageUrl or "about:blank",
                })
                self.previousPageUrl = self.currentPageUrl

                logger.info(f"Page navigated: {self.currentPageUrl}")
                try:
                    await self._processPage(page, ws)
                except Exception as e:
                    logger.error(f"Error processing page after navigation: {e}")
                    logger.error(tb.format_exc())
                finally:
                    await self.changeInteractionStatus(ws, False)
        else:
            logger.info(f"Frame navigated: {frame.url}")


if __name__ == "__main__":
    asyncio.run(Recorder(
        start_url="https://app.development.hirelogic.com", development=True).run())
