#!/usr/bin/env python3

from __future__ import division
import argparse
import re
import os

# Usage: $ ./are-we-synapse-yet.py [-v] results.tap
# This script scans a results.tap file from Dendrite's CI process and spits out
# a rating of how close we are to Synapse parity, based purely on SyTests.
# The main complexity is grouping tests sensibly into features like 'Registration'
# and 'Federation'. Then it just checks the ones which are passing and calculates
# percentages for each group. Produces results like:
# 
# Client-Server APIs: 29% (196/666 tests)
# -------------------
#   Registration             :  62% (20/32 tests)
#   Login                    :   7% (1/15 tests)
#   V1 CS APIs               :  10% (3/30 tests)
#   ...
#
# or in verbose mode:
#
# Client-Server APIs: 29% (196/666 tests)
# -------------------
#  Registration             :  62% (20/32 tests)
#    ✓ GET /register yields a set of flows
#    ✓ POST /register can create a user
#    ✓ POST /register downcases capitals in usernames
#    ...
# 
# You can also tack `-v` on to see exactly which tests each category falls under.

test_mappings = {
    "nsp": "Non-Spec API",
    "unk": "Unknown API (no group specified)",
    "app": "Application Services API",
    "msc": "MSCs",
    "f": "Federation", # flag to mark test involves federation

    "federation_apis": {
        "fky": "Key API",
        "fsj": "send_join API",
        "fmj": "make_join API",
        "fsl": "send_leave API",
        "fiv": "Invite API",
        "fqu": "Query API",
        "frv": "room versions",
        "fau": "Auth",
        "fbk": "Backfill API",
        "fme": "get_missing_events API",
        "fst": "State APIs",
        "fpb": "Public Room API",
        "fdk": "Device Key APIs",
        "fed": "Federation API",
		"fsd": "Send-to-Device APIs",
    },

    "client_apis": {
        "reg": "Registration",
        "log": "Login",
        "lox": "Logout",
        "v1s": "V1 CS APIs",
        "csa": "Misc CS APIs",
        "pro": "Profile",
        "dev": "Devices",
        "dvk": "Device Keys",
        "dkb": "Device Key Backup",
        "xsk": "Cross-signing Keys",
        "pre": "Presence",
        "crm": "Create Room",
        "syn": "Sync API",
        "rmv": "Room Versions",
        "rst": "Room State APIs",
        "pub": "Public Room APIs",
        "mem": "Room Membership",
        "ali": "Room Aliases",
        "jon": "Joining Rooms",
        "lev": "Leaving Rooms",
        "inv": "Inviting users to Rooms",
        "ban": "Banning users",
        "snd": "Sending events",
        "get": "Getting events for Rooms",
        "rct": "Receipts",
        "red": "Read markers",
        "med": "Media APIs",
        "cap": "Capabilities API",
        "typ": "Typing API",
        "psh": "Push APIs",
        "acc": "Account APIs",
        "eph": "Ephemeral Events",
        "plv": "Power Levels",
        "xxx": "Redaction",
        "3pd": "Third-Party ID APIs",
        "gst": "Guest APIs",
        "ath": "Room Auth",
        "fgt": "Forget APIs",
        "ctx": "Context APIs",
        "upg": "Room Upgrade APIs",
        "tag": "Tagging APIs",
        "sch": "Search APIs",
        "oid": "OpenID API",
        "std": "Send-to-Device APIs",
        "adm": "Server Admin API",
        "ign": "Ignore Users",
        "udr": "User Directory APIs",
		"jso": "Enforced canonical JSON",
    },
}

# optional 'not ' with test number then anything but '#'
re_testname = re.compile(r"^(not )?ok [0-9]+ ([^#]+)")

# Parses lines like the following:
#
# SUCCESS:     ok 3 POST /register downcases capitals in usernames
# FAIL:        not ok 54 (expected fail) POST /createRoom creates a room with the given version
# SKIP:        ok 821 Multiple calls to /sync should not cause 500 errors # skip lack of can_post_room_receipts
# EXPECT FAIL: not ok 822 (expected fail) Guest user can call /events on another world_readable room (SYN-606) # TODO expected fail
#
# Only SUCCESS lines are treated as success, the rest are not implemented.
#
# Returns a dict like:
# { name: "...", ok: True }
def parse_test_line(line):
    if not line.startswith("ok ") and not line.startswith("not ok "):
        return
    re_match = re_testname.match(line)
    test_name = re_match.groups()[1].replace("(expected fail) ", "").strip()
    test_pass = False
    if line.startswith("ok ") and not "# skip " in line:
        test_pass = True
    return {
        "name": test_name,
        "ok": test_pass,
    }

# Prints the stats for a complete section.
#   header_name => "Client-Server APIs"
#   gid_to_tests => { gid: { <name>: True|False }}
#   gid_to_name  => { gid: "Group Name" }
#   verbose => True|False
# Produces:
# Client-Server APIs: 29% (196/666 tests)
# -------------------
#   Registration             :  62% (20/32 tests)
#   Login                    :   7% (1/15 tests)
#   V1 CS APIs               :  10% (3/30 tests)
#   ...
# or in verbose mode:
# Client-Server APIs: 29% (196/666 tests)
# -------------------
#  Registration             :  62% (20/32 tests)
#    ✓ GET /register yields a set of flows
#    ✓ POST /register can create a user
#    ✓ POST /register downcases capitals in usernames
#    ...
def print_stats(header_name, gid_to_tests, gid_to_name, verbose):
    ci = os.getenv("CI") # When running from GHA, this groups the subsections
    subsections = [] # Registration: 100% (13/13 tests)
    subsection_test_names = {} # 'subsection name': ["✓ Test 1", "✓ Test 2", "× Test 3"]
    total_passing = 0
    total_tests = 0
    for gid, tests in gid_to_tests.items():
        group_total = len(tests)
        if group_total == 0:
            continue
        group_passing = 0
        test_names_and_marks = []
        for name, passing in tests.items():
            if passing:
                group_passing += 1
            test_names_and_marks.append(f"{'✅' if passing else '❌'} {name}")
            
        total_tests += group_total
        total_passing += group_passing
        pct = "{0:.0f}%".format(group_passing/group_total * 100)
        line = "%s: %s (%d/%d tests)" % (gid_to_name[gid].ljust(25, ' '), pct.rjust(4, ' '), group_passing, group_total)
        subsections.append(line)
        subsection_test_names[line] = test_names_and_marks

    # avoid errors when trying to divide by 0
    if total_tests == 0:
        return
    
    pct = "{0:.0f}%".format(total_passing/total_tests * 100)
    print("%s: %s (%d/%d tests)" % (header_name, pct, total_passing, total_tests))
    print("-" * (len(header_name)+1))
    for line in subsections:
        print("%s%s" % ("::group::" if ci and verbose else "", line,))
        if verbose:
            for test_name_and_pass_mark in subsection_test_names[line]:
                print("    %s" % (test_name_and_pass_mark,))
            print("%s" % ("::endgroup::" if ci else ""))
    print("")

def main(results_tap_path, verbose):
    # Load up test mappings
    test_name_to_group_id = {}
    fed_tests = set()
    client_tests = set()
    with open("./are-we-synapse-yet.list", "r") as f:
        for line in f.readlines():
            test_name = " ".join(line.split(" ")[1:]).strip()
            groups = line.split(" ")[0].split(",")
            for gid in groups:
                if gid == "f" or gid in test_mappings["federation_apis"]:
                    fed_tests.add(test_name)
                else:
                    client_tests.add(test_name)
                if gid == "f":
                    continue # we expect another group ID
                test_name_to_group_id[test_name] = gid

    # parse results.tap
    summary = {
        "client": {
            # gid: {
            #   test_name: OK
            # }
        },
        "federation": {
            # gid: {
            #   test_name: OK
            # }
        },
        "appservice": {
            "app": {},
        },
        "nonspec": {
            "nsp": {},
            "msc": {},
            "unk": {}
        },
    }
    with open(results_tap_path, "r") as f:
        for line in f.readlines():
            test_result = parse_test_line(line)
            if not test_result:
                continue
            name = test_result["name"]
            group_id = test_name_to_group_id.get(name)
            if not group_id:
                summary["nonspec"]["unk"][name] = test_result["ok"]
            if group_id == "nsp":
                summary["nonspec"]["nsp"][name] = test_result["ok"]
            elif group_id == "msc":
                summary["nonspec"]["msc"][name] = test_result["ok"]
            elif group_id == "app":
                summary["appservice"]["app"][name] = test_result["ok"]
            elif group_id in test_mappings["federation_apis"]:
                group = summary["federation"].get(group_id, {})
                group[name] = test_result["ok"]
                summary["federation"][group_id] = group
            elif group_id in test_mappings["client_apis"]:
                group = summary["client"].get(group_id, {})
                group[name] = test_result["ok"]
                summary["client"][group_id] = group

    print("Are We Synapse Yet?")
    print("===================")
    print("")
    print_stats("Non-Spec APIs", summary["nonspec"], test_mappings, verbose)
    print_stats("Client-Server APIs", summary["client"], test_mappings["client_apis"], verbose)
    print_stats("Federation APIs", summary["federation"], test_mappings["federation_apis"], verbose)
    print_stats("Application Services APIs", summary["appservice"], test_mappings, verbose)



if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("tap_file", help="path to results.tap")
    parser.add_argument("-v", action="store_true", help="show individual test names in output")
    args = parser.parse_args()
    main(args.tap_file, args.v)