#!/usr/bin/env python3.13 -B
"""
Helper script for running the PyObjC test suite
"""

import argparse
import collections
import configparser
import json
import logging
import os
import re
import shutil
import ssl
import subprocess
import sys
import time
import textwrap
from http.client import HTTPSConnection
from urllib.parse import urlencode

from _common_definitions import (
    TEST_STATE_DIR,
    TOP_DIR,
    setup_variant,
    system_report,
    variants,
    virtualenv,
    sort_framework_wrappers,
    detect_pyversions,
    RED,
    BOLD,
    RESET,
)

HTML_PREFIX = textwrap.dedent(
    """\
    <html>
      <head>
        <title>PyObjC Test Result</title>
        <style type="text/css">
          table, th, td { border: 1px solid grey; border-collapse: collapse; }
          th { font-style: bold }
          .errors { background-color: red; color: white; border-radius: 5px }
          .errors:before { content:" Errors: "}
          .errors:after { content:" "}
          .fails { background-color: orange; color: white; border-radius: 5px }
          .fails:before { content:" Fails: "}
          .fails:after { content:" "}
          .crash { background-color: red; color: white; border-radius: 5px }
          .skip { background-color: light-green; color: white; border-radius: 5px }
          .ok { background-color: green; color: white; border-radius: 5px }
          .ok:before { content:" "}
          .ok:after { content:" "}
        </style>
      </head>
      <body>
    """
)

HTML_SUFFIX = textwrap.dedent(
    """\
      </body>
    </html>
    """
)


def load_pushover_keys():
    cfg = configparser.ConfigParser()
    cfg.read([os.path.expanduser("~/.pushover-key")])

    result = {}

    if not cfg.has_section("pushover"):
        return result

    if cfg.has_option("pushover", "app"):
        result["app"] = cfg.get("pushover", "app")

    if cfg.has_option("pushover", "user"):
        result["user"] = cfg.get("pushover", "user")

    return result


def send_pushover(keys, message):
    if "app" not in keys or "user" not in keys:
        return

    body = {
        "priority": 1,
        "token": keys["app"],
        "user": keys["user"],
        "message": message,
    }

    context = ssl.create_default_context()
    context.check_hostname = False
    context.verify_mode = ssl.CERT_NONE
    conn = HTTPSConnection("api.pushover.net:443", context=context)
    conn.request(
        "POST",
        "/1/messages.json",
        urlencode(body),
        {"Content-Type": "application/x-www-form-urlencoded"},
    )
    conn.getresponse()


def parse_arguments():
    parser = argparse.ArgumentParser(description="Run PyObjC testsuite")
    parser.add_argument(
        "--python-version",
        dest="python_versions",
        metavar="VER",
        action="append",
        default=[],
        help="Select python version to test (%s)"
        % (", ".join(detect_pyversions(include_alpha=True))),
    )

    parser.add_argument(
        "--arch",
        dest="arch",
        metavar="ARCH",
        action="append",
        default=[],
        help="Select the system architectures to test (defaults to all)",
    )

    parser.add_argument(
        "--state-dir",
        dest="state_dir",
        metavar="DIR",
        default=TEST_STATE_DIR,
        help="Directory to store test results (%(default)s)",
    )

    parser.add_argument(
        "--variant",
        dest="permitted_variants",
        metavar="VAR",
        action="append",
        default=[],
        help="Restrict python variants to test (default: no restrictions)",
    )

    parser.add_argument(
        "--gil",
        dest="gil_enabled",
        metavar="VAL",
        action="append",
        default=[],
        help="Which -Xgil=VAL values should be tested for the free-threaded build (defaults to 0 and 1)",
    )

    result = parser.parse_args()
    if not result.python_versions:
        result.python_versions = detect_pyversions(include_alpha=True)

    return result


def build_project(*, interpreter, py_ver, project, idx, count):
    proj_dir = os.path.join(TOP_DIR, project)

    # Clean up any existing build artifacts
    if os.path.exists(os.path.join(proj_dir, "build")):
        shutil.rmtree(os.path.join(proj_dir, "build"))
    if os.path.exists(os.path.join(proj_dir, "dist")):
        shutil.rmtree(os.path.join(proj_dir, "dist"))

    # Actually build
    print(
        f"{BOLD}[{idx}/{count}] Building {project} using {interpreter} ({py_ver}){RESET}"
    )
    status = subprocess.call(
        [interpreter, "-mpip", "install", "--no-deps", "--no-cache-dir", "."],
        cwd=proj_dir,
    )
    if status != 0:
        print(f"{RED}build {project} failed (status {status}){RESET}")
        return False

    print(f"checking install {project} using {interpreter} ({py_ver})")
    status = subprocess.call(
        [
            interpreter,
            "-c",
            "import importlib.metadata; importlib.metadata.metadata(%r)" % (project),
        ]
    )
    if status != 0:
        print(f"{RED}build {project} failed: package not actually installed?{RESET}")
        return False

    return True


def run_tests(
    *, interpreter, arch, py_ver, project, state_dir, extra_py_args, idx, count
):
    proj_dir = os.path.join(TOP_DIR, project)

    print(
        f"{BOLD}[{idx}/{count}] Testing {project} using {interpreter} ({py_ver}, {arch}) {' '.join(extra_py_args)}{RESET}"
    )
    k = {}
    k["env"] = os.environ.copy()
    k["env"]["PATH"] = os.path.dirname(interpreter) + ":" + k["env"]["PATH"]

    if not os.path.exists(state_dir):
        os.makedirs(state_dir)

    with (
        open(os.path.join(state_dir, project + ".stdout"), "wb") as fp_stdout,
        open(os.path.join(state_dir, project + ".stderr"), "wb") as fp_stderr,
    ):
        p = subprocess.Popen(
            [
                "/usr/bin/arch",
                f"-{arch}",
                interpreter,
            ]
            + extra_py_args
            + [
                # XXX: Coverage support is disabled for now because
                # I've never actually used it.
                # "-mcoverage",
                # "run",
                # "--branch",
                # "--parallel",
                "setup.py",
                "test",
                "-v",
            ],
            cwd=proj_dir,
            stdout=fp_stdout,
            stderr=fp_stderr,
            **k,
        )
        exitcode = p.wait()

    with open(os.path.join(state_dir, project + ".stdout")) as fp_stdout:
        stdout = fp_stdout.read()

    try:
        status_line = stdout.rsplit("\n", 2)[-2]
        if not status_line.startswith("SUMMARY"):
            status = {
                "message": "No status line at end",
            }
        else:
            status = eval(status_line.split(None, 1)[1])

    except IndexError:
        status = {
            "message": "Cannot fetch status line",
            "stdout": stdout.decode("utf-8"),
        }

    status["exitcode"] = exitcode

    with open(os.path.join(state_dir, project + ".status"), "w") as fp:
        json.dump(status, fp)


def test_summary(fp_out, state_dir):
    print("Build information")
    print("=================")
    with open(os.path.join(state_dir, "build-info.txt")) as fp:
        fp_out.write(fp.read())

    results = collections.defaultdict(dict)

    for nm in os.listdir(state_dir):
        if nm.endswith(".txt") or nm.endswith(".html"):
            continue

        sd = os.path.join(state_dir, nm)
        for fn in os.listdir(sd):
            if not fn.endswith(".status"):
                continue
            proj = fn[:-7]
            proj = proj.replace("pyobjc-framework-", "FWK: ")

            with open(os.path.join(sd, fn)) as fp:
                results[proj][nm] = json.load(fp)

    keys = set()
    for v in results.values():
        keys.update(v.keys())
    keys = tuple(sorted(keys))
    width = max(len(x) for x in keys)

    maxlen = max(len(s) for s in results)
    fmt = f"%{maxlen + 2}s" + (" | %%%ds" % width) * len(keys)
    print(file=fp_out)
    print()
    print(fmt % (("Project",) + keys), file=fp_out)
    print(
        (fmt % ((len(keys) + 1) * ("",))).replace(" ", "=").replace("|", "+"),
        file=fp_out,
    )

    ok = True
    for proj in sorted(results):
        # Note: DictionaryServices has a known crashing testcase,
        # ignore errors w.r.t. reporting if there were problems in the test run.
        row = [proj]
        for k in keys:
            if k not in results[proj]:
                row.append("skip")
                continue
            else:
                info = results[proj][k]
            if info.get("errors"):
                row.append(f"E:{info['errors']}")
            elif info.get("fails"):
                row.append(f"F:{info['fails']}")
            elif "count" not in info and info.get("message"):
                row.append("CRASH")
            else:
                row.append("")

        print(fmt % tuple(row), file=fp_out)

    return ok


def parse_build_info(state_dir):
    info = {}
    python_versions = {}

    with open(f"{state_dir}/build-info.txt") as fp:
        for line in fp:
            if not line.strip():
                break

            key, _, value = line.strip().partition(":")
            info[key.strip()] = value.strip()

        for line in fp:
            version, _, rest = line.partition(":")
            _, _, version = version.partition(" ")
            rest = rest.strip()
            python_versions[version] = rest

    return info, python_versions


def test_summary_html(fp_out, state_dir):
    fp_out.write(HTML_PREFIX)

    info, python_versions = parse_build_info(state_dir)

    print("  <h1>Build information</h1>", file=fp_out)
    print("  <table>", file=fp_out)
    print("    <tr><th>Key</th><th>Value</th></tr>", file=fp_out)
    for key, value in info.items():
        print(f"    <tr><td>{key}</td><td>{value}</td></tr>", file=fp_out)
    print("  </table>", file=fp_out)

    print("  <h1>Python versions</h1>", file=fp_out)
    print("  <table>", file=fp_out)
    print("    <tr><th>Versions</th><th>Details</th></tr>", file=fp_out)
    for key, value in python_versions.items():
        print(f"    <tr><td>{key}</td><td>{value}</td></tr>", file=fp_out)
    print("  </table>", file=fp_out)

    results = collections.defaultdict(dict)

    for nm in os.listdir(state_dir):
        if nm.endswith(".txt") or nm.endswith(".html"):
            continue

        sd = os.path.join(state_dir, nm)
        for fn in os.listdir(sd):
            if not fn.endswith(".status"):
                continue
            proj = fn[:-7]
            proj = proj.replace("pyobjc-framework-", "FWK: ")

            with open(os.path.join(sd, fn)) as fp:
                results[proj][nm] = json.load(fp)

    keys = set()
    for v in results.values():
        keys.update(v.keys())
    keys = tuple(sorted(keys))

    print("  <h1>Test Results</h1>", file=fp_out)
    print("  <table>", file=fp_out)
    print("    <tr>", file=fp_out)
    print("      <th>Project</th>", file=fp_out)
    for k in keys:
        print(f"      <th>{k}</th>", file=fp_out)
    print("    </tr>", file=fp_out)

    for proj in sorted(results):
        # Note: DictionaryServices has a known crashing testcase,
        # ignore errors w.r.t. reporting if there were problems in the test run.
        print("    <tr>", file=fp_out)
        print(f"      <td>{proj}</td>", file=fp_out)
        for k in keys:
            if k not in results[proj]:
                print(
                    "      <td><span class=\"skip\"></span></td>",
                    file=fp_out,
                )
                continue
            else:
                info = results[proj][k]
            errors = info.get("errors", 0)
            fails = info.get("fails", 0)
            oks = info.get("count", 0)

            if "count" not in info and info.get("message"):
                print(
                    f"      <td><span class=\"crash\">{info.get('message')}</span></td>",
                    file=fp_out,
                )
            else:
                parts = []
                if errors:
                    parts.append(f'<span class="errors">{errors}</span>')
                if fails:
                    parts.append(f'<span class="fails">{fails}</span>')

                if parts:
                    print(f"      <td>{' '.join(parts)}</td>", file=fp_out)
                elif not oks:
                    print("      <td></td>", file=fp_out)
                else:
                    print('      <td><span class="ok">OK</span></td>', file=fp_out)

    print("    </tr>", file=fp_out)

    print("  </table>", file=fp_out)
    fp_out.write(HTML_SUFFIX)


def system_supports_arch(interpreter, arch):
    try:
        subprocess.check_output(["/usr/bin/arch", f"-{arch}", interpreter, "-c", ""])
    except subprocess.CalledProcessError:
        return False

    return True


def supports_arch(interpreter, arch):
    lines = subprocess.check_output(["/usr/bin/file", interpreter])
    lines = lines.decode("utf-8").splitlines()
    for ln in lines:
        # For fat binaries:
        m = re.search(r"for architecture (\S+)\)", ln)
        if m is not None:
            if m.group(1).startswith(arch):
                return True

        # For single-arch binaries:
        m = re.search(r"Mach-O .* executable (\S+)", ln)
        if m is not None:
            if m.group(1).startswith(arch):
                return True

    return False


def compiler_supports_arch(arch):
    if os.path.exists("/tmp/detect"):
        os.unlink("/tmp/detect")
    with open("/tmp/detect.c", "w") as fp:
        fp.write("int main(void) { return 42; }\n")

    try:
        subprocess.check_call(
            ["cc", "-o", "/tmp/detect", "/tmp/detect.c", "-arch", arch]
        )
    except subprocess.CalledProcessError:
        os.unlink("/tmp/detect.c")
        return False

    os.unlink("/tmp/detect.c")
    if not os.path.exists("/tmp/detect"):
        return False

    os.unlink("/tmp/detect")
    return True


def format_seconds(seconds):
    seconds = int(seconds)

    minutes, seconds = divmod(seconds, 60)
    hours, minutes = divmod(minutes, 60)

    if hours:
        return "%d:%02d:%02d hours" % (hours, minutes, seconds)

    elif minutes:
        return "%02d:%02d minutes" % (minutes, seconds)

    else:
        return "%d seconds" % (seconds,)


def main():
    start_time = time.time()
    logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
    options = parse_arguments()

    # The exceptionhandling testsuite calls atos and that can ask for a password.
    # Run the executable now to avoid problems later
    print("Please ignore the atos output below")
    subprocess.call(["/usr/bin/atos", "/bin/sh", "0x0"])

    # Invoke 'sw_vers' to get the real system version, even if Python was compiled
    # with an older SDK.
    osx_release = (
        subprocess.check_output(["sw_vers", "-productVersion"]).decode().strip()
    )

    print(
        f"{BOLD}running PyObjC tests on macOS {osx_release} with python versions {', '.join(options.python_versions)}{RESET}"
    )
    osx_dir = os.path.join(options.state_dir, ".".join(osx_release.split(".")[:2]))
    if not os.path.exists(osx_dir):
        os.makedirs(osx_dir)

    system_report(os.path.join(osx_dir, "build-info.txt"), options.python_versions)

    build_order = ["pyobjc-core"] + sort_framework_wrappers()

    # Clear coverage directories to avoid using old coverage information
    for project in build_order:
        cov_dir = os.path.join(TOP_DIR, project, ".coverage")
        if os.path.isdir(cov_dir):
            shutil.rmtree(cov_dir)
        elif os.path.isfile(cov_dir):
            os.unlink(cov_dir)

    for py_ver in options.python_versions:
        for variant in variants(py_ver, options.permitted_variants):
            print(f"{BOLD}Preparing variant {variant!r} of python {py_ver}{RESET}")

            setup_variant(py_ver, variant)

            with virtualenv(f"python{py_ver}") as interpreter:
                # Install tools
                subprocess.check_call(
                    [
                        interpreter,
                        "-mpip",
                        "install",
                        "--no-warn-script-location",
                        "-U",
                        "coverage",
                        # For some reason 'chardet' is needed for some
                        # 'pyobjc' tests on x86_64 (in emulation)
                        "chardet",
                    ]
                )

                # Install packages into the virtualenv
                for idx, project in enumerate(build_order):
                    build_project(
                        interpreter=interpreter,
                        py_ver=variant,
                        project=project,
                        idx=idx,
                        count=len(build_order),
                    )

                if py_ver == options.python_versions[0]:
                    # Run the 'pyobjc' tests only once. These
                    # tests are fairly expensive and test basic
                    # housekeeping for the project as a whole.
                    #
                    # Running them for every element of the
                    # test matrix is not useful.
                    for arch in ("arm64", "x86_64"):
                        if not system_supports_arch(interpreter, arch):
                            continue
                        if options.arch and arch not in options.arch:
                            continue
                        if supports_arch(interpreter, arch):
                            break
                    else:
                        raise RuntimeError("Cannot find supported architecture")

                    run_tests(
                        interpreter=interpreter,
                        arch=arch,
                        py_ver=variant,
                        project="pyobjc",
                        state_dir=os.path.join(osx_dir, f"{variant}-{arch}"),
                        extra_py_args=[],
                        idx=0,
                        count=len(build_order) + 1,
                    )

                # Then run tests for all supported architectures
                for arch in ("x86_64", "arm64"):
                    if options.arch and arch not in options.arch:
                        continue

                    if not supports_arch(interpreter, arch):
                        print(
                            f"skipping Python {variant} ({arch}), unsupported architecture"
                        )
                        continue

                    if not system_supports_arch(interpreter, arch):
                        print(
                            f"skipping Python {variant} ({arch}), system cannot run architecture"
                        )
                        continue

                    if not compiler_supports_arch(arch):
                        print(
                            f"skipping Python {variant} ({arch}), compiler cannot build for architecture"
                        )
                        continue

                    print(
                        f"{BOLD}running with Python {variant} ({arch}s) using {interpreter}{RESET}"
                    )

                    if py_ver.endswith("t"):
                        # Test free-threaded builds both with and without the GIL enabled.
                        if options.gil_enabled:
                            extra_args = tuple(
                                [f"-Xgil={value}"] for value in options.gil_enabled
                            )
                        else:
                            extra_args = (["-Xgil=1"], ["-Xgil=0"])
                    else:
                        extra_args = [[]]

                    for extra in extra_args:
                        state_dir = os.path.join(osx_dir, f"{variant}-{arch}")
                        if extra:
                            state_dir = state_dir + "-" + "~".join(extra)

                        for idx, project in enumerate(build_order):
                            run_tests(
                                interpreter=interpreter,
                                arch=arch,
                                py_ver=variant,
                                project=project,
                                state_dir=state_dir,
                                extra_py_args=extra,
                                idx=idx + 1,
                                count=len(build_order) + 1,
                            )

    print(f"{BOLD}done{RESET}")
    with open(os.path.join(osx_dir, "summary.txt"), "w") as fp:
        test_summary(fp, osx_dir)

    with open(os.path.join(osx_dir, "summary.html"), "w") as fp:
        test_summary_html(fp, osx_dir)

    ok = test_summary(sys.stdout, osx_dir)

    keys = load_pushover_keys()
    send_pushover(
        keys, "Testsuite finished" if ok else "Testsuite finished with errors"
    )

    # Save HTML coverage reports
    # TODO: Find a way to combine all of this in 1 big report
    # with virtualenv("python{}".format(options.python_versions[-1])) as interpreter:
    #    subprocess.check_call(
    #        [interpreter, "-mpip", "install", "--no-warn-script-location", "coverage"]
    #    )
    #    for project in build_order:
    #        proj_dir = os.path.join(TOP_DIR, project)
    #        if not os.path.exists(os.path.join(proj_dir, ".coverage")):
    #            continue
    #
    #        subprocess.check_call([interpreter, "-mcoverage", "combine"], cwd=proj_dir)
    #        subprocess.check_call(
    #            [
    #                interpreter,
    #                "-mcoverage",
    #                "html",
    #                "--include=Lib/*",
    #                "--omit=*/_metadata.py",
    #                "--title=%s" % (project,),
    #            ],
    #            cwd=proj_dir,
    #        )

    end_time = time.time()
    print("Testing took", format_seconds(end_time - start_time))

    if not ok:
        print()
        print("ERROR: some tests have failures")
        sys.exit(1)


if __name__ == "__main__":
    main()
