from pathlib import Path
from typing import List, Optional, Set, Tuple

from openwpm.config import BrowserParams, ManagerParams
from openwpm.utilities import db_utils

from . import utilities as util
from .openwpm_jstest import OpenWPMJSTest


class TestJSInstrumentNonExistingWindowProperty(OpenWPMJSTest):
    # Since the window property remains non-existing, attempts to
    # access the window property's attributes first fails when evaluating
    # the non-existing window property, thus preventing us from receiving
    # events about the attributes that were attempted to be accessed
    # This is why we don't see any gets to window.nonExisting.nonExistingProp1
    # etc below

    GETS_AND_SETS = {
        ("window.nonExisting", "get", "undefined"),
        ("window.nonExisting", "get", "undefined"),
        ("window.nonExisting", "get", "undefined"),
        ("window.nonExisting", "get", "undefined"),
        ("window.nonExisting", "get", "undefined"),
        ("window.nonExisting", "get", "undefined"),
    }

    METHOD_CALLS: Set[Tuple[str, str, str]] = set()

    TEST_PAGE = "instrument_non_existing_window_property.html"
    TOP_URL = "%s/js_instrument/%s" % (util.BASE_TEST_URL, TEST_PAGE)

    def test_instrument_object(self):
        """Ensure instrumentObject logs all property gets, sets, and calls"""
        db = self.visit("/js_instrument/%s" % self.TEST_PAGE)
        self._check_calls(
            db=db,
            symbol_prefix="",
            doc_url=self.TOP_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )


class TestJSInstrumentExistingWindowProperty(OpenWPMJSTest):
    GETS_AND_SETS = {
        ("window.partiallyExisting", "get", '{"existingProp":"foo"}'),
        ("window.partiallyExisting", "get", '{"existingProp":"foo"}'),
        ("window.partiallyExisting", "get", '{"existingProp":"foo"}'),
        ("window.partiallyExisting", "get", '{"existingProp":"foo"}'),
        ("window.partiallyExisting.existingProp", "get", "foo"),
        ("window.partiallyExisting", "get", '{"existingProp":"foo"}'),
        ("window.partiallyExisting.existingProp", "set", "blah1"),
        ("window.partiallyExisting", "get", '{"existingProp":"blah1"}'),
        ("window.partiallyExisting.existingProp", "get", "blah1"),
        ("window.partiallyExisting", "get", '{"existingProp":"blah1"}'),
        ("window.partiallyExisting.nonExistingProp1", "get", "undefined"),
        ("window.partiallyExisting", "get", '{"existingProp":"blah1"}'),
        ("window.partiallyExisting.nonExistingProp1", "set", "blah1"),
        ("window.partiallyExisting", "get", '{"existingProp":"blah1"}'),
        ("window.partiallyExisting.nonExistingProp1", "get", "blah1"),
        ("window.partiallyExisting", "get", '{"existingProp":"blah1"}'),  # Note 1
        ("window.partiallyExisting.nonExistingMethod1", "get", "undefined"),  # Note 2
    }

    # Note 1: nonExistingProp1 is not enumerable even after being set
    # Note 2: nonExistingMethod1 shows up as a get rather than call

    METHOD_CALLS: Set[Tuple[str, str, str]] = set()  # Note 2

    TEST_PAGE = "instrument_existing_window_property.html"
    TOP_URL = "%s/js_instrument/%s" % (util.BASE_TEST_URL, TEST_PAGE)

    def test_instrument_object(self):
        """Ensure instrumentObject logs all property gets, sets, and calls"""
        db = self.visit("/js_instrument/%s" % self.TEST_PAGE)
        self._check_calls(
            db=db,
            symbol_prefix="",
            doc_url=self.TOP_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )


class TestJSInstrumentByPython(OpenWPMJSTest):  # noqa
    # This test tests python side configuration. But we can only test
    # built in browser APIs, so we're not using html specced objects.
    TEST_PAGE = "instrument_pyside.html"
    TOP_URL = "%s/js_instrument/%s" % (util.BASE_TEST_URL, TEST_PAGE)

    GETS_AND_SETS = {
        ("window.navigator.webdriver", "get", "true"),
        ("window.document.cookie", "set", "a=COOKIE"),
        ("window.document.cookie", "get", "a=COOKIE"),
    }
    METHOD_CALLS = {
        # In the JS we use both fetch and window.fetch
        ("window.fetch", "call", '["https://example.com"]'),
        ("window.fetch", "call", '["https://example.org"]'),
    }

    def get_config(
        self, data_dir: Optional[Path]
    ) -> Tuple[ManagerParams, List[BrowserParams]]:
        manager_params, browser_params = super().get_config(data_dir)
        browser_params[0].prefs = {
            "network.dns.localDomains": "example.com,example.org"
        }
        browser_params[0].js_instrument_settings = [
            # Note that the string "window.document.cookie" does not work.
            {
                "window.document": [
                    "cookie",
                ]
            },
            {
                "window.navigator": [
                    "webdriver",
                ]
            },
            {
                "window": [
                    "fetch",
                ]
            },
        ]
        return manager_params, browser_params

    def test_instrument_object(self):
        """Ensure instrumentObject logs all property gets, sets, and calls"""
        db = self.visit("/js_instrument/%s" % self.TEST_PAGE)
        self._check_calls(
            db=db,
            symbol_prefix="",
            doc_url=self.TOP_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )


class TestJSInstrumentMockWindowProperty(OpenWPMJSTest):
    GETS_AND_SETS = {
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingProp1", "get", "undefined"),
        (
            "window.alreadyInstantiatedMockClassInstance.nonExistingProp1",
            "get",
            "undefined",
        ),
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingProp1", "set", "blah1"),
        (
            "window.alreadyInstantiatedMockClassInstance.nonExistingProp1",
            "set",
            "blah1",
        ),
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingProp1", "get", "blah1"),
        (
            "window.alreadyInstantiatedMockClassInstance.nonExistingProp1",
            "get",
            "blah1",
        ),
        ("window.alreadyInstantiatedMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingMethod1", "get", "undefined"),  # Note 1
        (
            "window.alreadyInstantiatedMockClassInstance.nonExistingMethod1",
            "get",
            "undefined",
        ),  # Note 1
        ("window.newMockClassInstance", "get", "{}"),
        ("window.newMockClassInstance", "get", "{}"),
        ("window.newMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingProp1", "get", "blah1"),  # Note 2
        ("window.newMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingProp1", "set", "blah1"),
        ("window.newMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingProp1", "get", "blah1"),
        ("window.newMockClassInstance", "get", "{}"),
        ("window.MockClass.nonExistingMethod1", "get", "undefined"),  # Note 1
    }

    # Note 1: nonExistingMethod1 shows up as a get rather than call
    # Note 2: This may be a bug - the new instance
    #         should not have a value here yet

    METHOD_CALLS = {
        ("window.MockClass", "call", None),
    }

    TEST_PAGE = "instrument_mock_window_property.html"
    TOP_URL = "%s/js_instrument/%s" % (util.BASE_TEST_URL, TEST_PAGE)

    def test_instrument_object(self):
        """Ensure instrumentObject logs all property gets, sets, and calls"""
        db = self.visit("/js_instrument/%s" % self.TEST_PAGE)

        self._check_calls(
            db=db,
            symbol_prefix="",
            doc_url=self.TOP_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )


class TestJSInstrument(OpenWPMJSTest):
    GETS_AND_SETS = {
        ("prop1", "get", "prop1"),
        ("prop1", "set", "blah1"),
        ("prop1", "get", "blah1"),
        ("prop2", "get", "prop2"),
        ("prop2", "set", "blah2"),
        ("prop2", "get", "blah2"),
        ("objProp", "get", '{"hello":"world"}'),
        ("objProp", "set", '{"key":"value"}'),
        ("objProp", "get", '{"key":"value"}'),
        ("prop3", "get", "default-value"),
        ("prop3", "set", "blah3"),
        ("prop3", "get", "blah3"),
        ("method1", "set", "FUNCTION"),
        ("method1", "set", "now static"),
        ("method1", "get", "now static"),
        ("prop1", "set", "FUNCTION"),
        (
            "nestedObj",
            "get",
            '{"prop1":"default1","prop2":"default2","method1":"FUNCTION"}',
        ),
    }

    METHOD_CALLS = {
        ("prop1", "call", '["now accepting arguments"]'),
        ("method1", "call", '["hello","{\\"world\\":true}"]'),
        ("method1", "call", '["new argument"]'),
    }

    RECURSIVE_GETS_AND_SETS = {
        ("window.test2.nestedObj.prop1", "get", "default1"),
        ("window.test2.nestedObj.prop1", "set", "updatedprop1"),
        ("window.test2.nestedObj.prop1", "get", "updatedprop1"),
        ("window.test2.nestedObj.prop2", "get", "default2"),
        ("window.test2.nestedObj.method1", "set", "FUNCTION"),
        ("window.test2.nestedObj.doubleNested.prop1", "get", "double default"),
        ("window.test2.nestedObj.doubleNested.prop1", "set", "doubleprop1"),
        ("window.test2.nestedObj.doubleNested.prop1", "get", "doubleprop1"),
        ("window.test2.nestedObj.doubleNested.method1", "set", "FUNCTION"),
    }

    RECURSIVE_METHOD_CALLS = {
        ("window.test2.nestedObj.method1", "call", '["arg-before"]'),
        ("window.test2.nestedObj.method1", "call", '["arg-after"]'),
        ("window.test2.nestedObj.doubleNested.method1", "call", '["blah"]'),
    }

    RECURSIVE_PROP_SET = {
        ("window.test2.l1.l2.l3.l4.l5.prop", "get", "level5prop"),
        ("window.test2.l1.l2.l3.l4.l5.l6", "get", '{"prop":"level6prop"}'),
    }

    SET_PREVENT_CALLS = {
        ("window.test3.method1", "call", None),
        ("window.test3.obj1.method2", "call", None),
    }

    SET_PREVENT_GETS_AND_SETS = {
        ("window.test3.prop1", "set", "newprop1"),
        ("window.test3.method1", "set(prevented)", "FUNCTION"),
        ("window.test3.obj1", "set(prevented)", '{"new":"object"}'),
        ("window.test3.obj1.prop2", "set", "newprop2"),
        ("window.test3.obj1.method2", "set(prevented)", "FUNCTION"),
        ("window.test3.obj1.obj2", "set(prevented)", '{"new":"object2"}'),
        ("window.test3.prop1", "get", "newprop1"),
        ("window.test3.obj1.obj2", "get", '{"testobj":"nested"}'),
        ("window.test3.obj1.prop2", "get", "newprop2"),
    }

    TOP_URL = "%s/js_instrument/instrument_object.html" % util.BASE_TEST_URL
    FRAME1_URL = "%s/js_instrument/framed1.html" % util.BASE_TEST_URL
    FRAME2_URL = "%s/js_instrument/framed2.html" % util.BASE_TEST_URL

    def test_instrument_object(self):
        """Ensure instrumentObject logs all property gets, sets, and calls"""
        db = self.visit("/js_instrument/instrument_object.html")
        self._check_calls(
            db=db,
            symbol_prefix="window.test.",
            doc_url=self.TOP_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )
        self._check_calls(
            db=db,
            symbol_prefix="window.frame1Test.",
            doc_url=self.FRAME1_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )
        self._check_calls(
            db=db,
            symbol_prefix="window.frame2Test.",
            doc_url=self.FRAME2_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )

        # Check calls of recursive instrumentation
        observed_gets_and_sets = set()
        observed_calls = set()
        rows = db_utils.get_javascript_entries(db, all_columns=True)
        for row in rows:
            if not row["symbol"].startswith("window.test2.nestedObj"):
                continue
            assert row["document_url"] == self.TOP_URL
            assert row["top_level_url"] == self.TOP_URL
            if row["operation"] == "get" or row["operation"] == "set":
                observed_gets_and_sets.add(
                    (row["symbol"], row["operation"], row["value"])
                )
            else:
                observed_calls.add((row["symbol"], row["operation"], row["arguments"]))
        assert observed_calls == self.RECURSIVE_METHOD_CALLS
        assert observed_gets_and_sets == self.RECURSIVE_GETS_AND_SETS

        # Check that calls not present after default recursion limit (5)
        # We should only see the window.test2.l1.l2.l3.l4.l5.prop access
        # and not window.test2.l1.l2.l3.l4.l5.l6.prop access.
        prop_access = set()
        for row in rows:
            if not row["symbol"].startswith("window.test2.l1"):
                continue
            assert row["document_url"] == self.TOP_URL
            assert row["top_level_url"] == self.TOP_URL
            prop_access.add((row["symbol"], row["operation"], row["value"]))
        assert prop_access == self.RECURSIVE_PROP_SET

        # Check calls of object with sets prevented
        observed_gets_and_sets = set()
        observed_calls = set()
        for row in rows:
            if not row["symbol"].startswith("window.test3"):
                continue
            assert row["document_url"] == self.TOP_URL
            assert row["top_level_url"] == self.TOP_URL
            if row["operation"] == "call":
                observed_calls.add((row["symbol"], row["operation"], row["arguments"]))
            else:
                observed_gets_and_sets.add(
                    (row["symbol"], row["operation"], row["value"])
                )
        assert observed_calls == self.SET_PREVENT_CALLS
        assert observed_gets_and_sets == self.SET_PREVENT_GETS_AND_SETS


class TestJSInstrumentRecursiveProperties(OpenWPMJSTest):
    # Since the window property remains non-existing, attempts to
    # access the window property's attributes first fails when evaluating
    # the non-existing window property, thus preventing us from receiving
    # events about the attributes that were attempted to be accessed
    # This is why we don't see any gets to window.nonExisting.nonExistingProp1
    # etc below

    GETS_AND_SETS = {
        ("window.test.prop1", "get", "test_prop1"),
        # At the nested level we have both because the propertiesToInstrument
        # was not propogated down.
        ("window.test.test.prop1", "get", "test_test_prop1"),
        ("window.test.test.prop2", "get", "test_test_prop2"),
    }

    METHOD_CALLS: Set[Tuple[str, str, str]] = set()

    TEST_PAGE = "instrument_do_not_recurse_properties_to_instrument.html"
    TOP_URL = "%s/js_instrument/%s" % (util.BASE_TEST_URL, TEST_PAGE)

    def test_instrument_object(self):
        """Ensure instrumentObject logs all property gets, sets, and calls"""
        db = self.visit("/js_instrument/%s" % self.TEST_PAGE)
        self._check_calls(
            db=db,
            symbol_prefix="",
            doc_url=self.TOP_URL,
            top_url=self.TOP_URL,
            expected_method_calls=self.METHOD_CALLS,
            expected_gets_and_sets=self.GETS_AND_SETS,
        )
