#!/usr/bin/env python3
#
# Validates that 3 data sources agree about the structure of Zulip's events API:
#
# * Node fixtures for the server_events_dispatch.js tests.
# * OpenAPI definitions in zerver/openapi/zulip.yaml
# * The schemas defined in zerver/lib/events_schema.py used for the
#   Zulip server's test suite.
#
# We compare the Python and OpenAPI schemas by converting the OpenAPI data
# into the event_schema style of types and the diffing the schemas.
import argparse
import difflib
import os
import subprocess
import sys
from collections.abc import Callable
from typing import Any

import orjson

TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(TOOLS_DIR))
ROOT_DIR = os.path.dirname(TOOLS_DIR)

EVENTS_JS = "web/tests/lib/events.js"

# check for the venv
from tools.lib import sanity_check

sanity_check.check_venv(__file__)

USAGE = """

    This program reads in fixture data for our
    node tests, and then it validates the fixture
    data with checkers from event_schema.py (which
    are the same Python functions we use to validate
    events in test_events.py).

    It currently takes no arguments.
"""

parser = argparse.ArgumentParser(usage=USAGE)
parser.parse_args()

# We can eliminate the django dependency in event_schema,
# but unfortunately it"s coupled to modules like validate.py
# and topic.py.
import django

os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.test_settings"
django.setup()

from zerver.lib import event_schema
from zerver.lib.data_types import (
    DictType,
    EnumType,
    ListType,
    NumberType,
    StringDictType,
    UnionType,
    make_checker,
    schema,
)
from zerver.openapi.openapi import openapi_spec

# This list of exemptions represents details we should fix in Zulip's
# API structure and/or validators.
EXEMPT_OPENAPI_NAMES = [
    # Additional keys(push_users_notify) due to bug in API.
    "message_event",
    # tuple handling
    "muted_topics_event",
    # bots, delivery_email, profile_data
    "realm_user_add_event",
    # OpenAPI is incomplete
    "realm_update_dict_event",
    # is_mirror_dummy
    "reaction_add_event",
    "reaction_remove_event",
]

# This is a list of events still documented in the OpenAPI that
# are deprecated and no longer checked in event_schema.py.
DEPRECATED_EVENTS = [
    "realm_filters_event",
]


def get_event_checker(event: dict[str, Any]) -> Callable[[str, dict[str, Any]], None] | None:
    name = event["type"]
    if "op" in event:
        name += "_" + event["op"]

    name += "_event"

    if hasattr(event_schema, name):
        return make_checker(getattr(event_schema, name))
    return None


def check_event(name: str, event: dict[str, Any]) -> None:
    event["id"] = 1
    checker = get_event_checker(event)
    if checker is not None:
        try:
            checker(name, event)
        except AssertionError:
            print(f"\n{EVENTS_JS} has bad data for {name}:\n\n")
            raise
    else:
        print(f"WARNING - NEED SCHEMA: {name}")


def read_fixtures() -> dict[str, Any]:
    cmd = [
        "node",
        os.path.join(TOOLS_DIR, "node_lib/dump_fixtures.js"),
    ]
    schema = subprocess.check_output(cmd)
    return orjson.loads(schema)


def verify_fixtures_are_sorted(names: list[str]) -> None:
    for i in range(1, len(names)):
        if names[i] < names[i - 1]:
            raise Exception(
                f"""
                Please keep your fixtures in order within
                your events.js file.  The following
                key is out of order

                {names[i]}
                """
            )


def from_openapi(node: dict[str, Any]) -> Any:
    """Converts the OpenAPI data into event_schema.py style type
    definitions for convenient comparison with the types used for backend
    tests declared there."""
    if "oneOf" in node:
        return UnionType([from_openapi(n) for n in node["oneOf"]])

    if node["type"] == "object":
        if (
            "additionalProperties" in node
            # this might be a glitch in our current spec?  or
            # maybe I just understand it yet
            and isinstance(node["additionalProperties"], dict)
        ):
            return StringDictType(from_openapi(node["additionalProperties"]))

        if "properties" not in node:
            return dict

        required_keys = []
        for key, sub_node in node["properties"].items():
            required_keys.append((key, from_openapi(sub_node)))
        return DictType(required_keys)

    if node["type"] == "boolean":
        return bool

    if node["type"] == "integer":
        if "enum" in node:
            return EnumType(node["enum"])
        return int

    if node["type"] == "number":
        return NumberType()

    if node["type"] == "string":
        if "enum" in node:
            return EnumType(node["enum"])
        return str

    if node["type"] == "array":
        return ListType(from_openapi(node["items"]))

    raise AssertionError("cannot handle node")


def validate_openapi_against_event_schema() -> None:
    node = openapi_spec.openapi()["paths"]["/events"]["get"]["responses"]["200"]["content"][
        "application/json"
    ]["schema"]["properties"]["events"]["items"]["oneOf"]

    for sub_node in node:
        name = sub_node["properties"]["type"]["enum"][0]
        if "op" in sub_node["properties"]:
            name += "_" + sub_node["properties"]["op"]["enum"][0]

        name += "_event"

        if not hasattr(event_schema, name):
            if name not in DEPRECATED_EVENTS:
                print("WARNING - NEED SCHEMA to match OpenAPI", name)
            continue

        openapi_type = from_openapi(sub_node)
        openapi_schema = schema(name, openapi_type)

        py_type = getattr(event_schema, name)
        py_schema = schema(name, py_type)

        if name in EXEMPT_OPENAPI_NAMES:
            if openapi_schema == py_schema:
                raise AssertionError(f"unnecessary exemption for {name}")
            continue

        if openapi_schema != py_schema:
            print(f"py\n{py_schema}\n")
            print(f"openapi\n{openapi_schema}\n")

            for line in difflib.unified_diff(
                py_schema.split("\n"),
                openapi_schema.split("\n"),
                fromfile="py",
                tofile="openapi",
            ):
                print(line)
            raise AssertionError("openapi schemas disagree")


def run() -> None:
    fixtures = read_fixtures()
    verify_fixtures_are_sorted(list(fixtures.keys()))
    for name, event in fixtures.items():
        check_event(name, event)
    validate_openapi_against_event_schema()
    print("Successful check. All tests passed.")


if __name__ == "__main__":
    run()
