"""
A benchmark to compare osrm-routed toolchains and/or osrm datasets.

This program lets you compare osrm-routed toolchains and/or datasets generated by
different toolchains. All datasets must have the same extent. You must provide a CSV
file containing (random or curated) points inside the dataset.

The run subcommand will select the first binary and/or dataset and request routes
between N pairs of randomly selected points.  It will then select the next binary and/or
dataset and request the same routes. The whole process will be repeated M times.

Ideally the arguments: `builds`, `datasets` and `logfiles` will have the same number
of items.  If not, they are extended by repeating the last item.

The `report` subcommand can be used to analyze logfiles generated by earlier runs.

The `csv` subcommand can be used to generate a sample coordinate file. Overpass query
that produces the sample coordinates file:

"""

import argparse
import csv
import datetime
import itertools
import os
import pathlib
import random
import re
import requests
import subprocess

import pandas as pd
import tabulate

OVERPASS_QUERY = """
[out:csv(::lat, ::lon, "population", "name")];

area["name"="Deutschland"];
node(area)[place="city"](if:number(t["population"]) > 100000);

out center;
"""


class Client:
    def __init__(self, args):
        self.host = args.host
        self.url = f"{self.host}/route/v1/driving"
        self.distance = 0

        self.coordinates = []
        """ list of (lon, lat) """
        with open(args.coordinates, newline="") as csvfile:
            reader = csv.DictReader(csvfile, delimiter="\t")
            for row in reader:
                self.coordinates.append((float(row["@lat"]), float(row["@lon"])))

    def make_url(self):
        def toString(coords) -> str:
            return ";".join([f"{coord[1]:.6f},{coord[0]:.6f}" for coord in coords])

        coords = random.sample(self.coordinates, 2)
        return f"{self.url}/{toString(coords)}?overview=full&steps=true"

    def make_request(self):
        """Make one routed request"""
        r = requests.get(self.make_url())
        r.raise_for_status()
        if r.json()["code"] != "Ok":
            raise requests.HTTPError("code not OK")
        self.distance = r.json()["routes"][0]["legs"][0]["distance"]


def zip_longest(*iterables):
    # zip_longest('ABCD', 'xy', '123') → Ax1 By2 Cy3 Dy3

    iterators = list(map(iter, iterables))
    num_active = len(iterators)
    if not num_active:
        return

    while True:
        values = []
        last_values = [None] * num_active
        for i, iterator in enumerate(iterators):
            try:
                value = next(iterator)
            except StopIteration:
                num_active -= 1
                if not num_active:
                    return
                value = last_values[i]
                iterators[i] = itertools.repeat(value)
            values.append(value)
        last_values = values
        yield tuple(values)


def run(args):
    for logfile in args.logfiles:
        path = pathlib.Path(logfile)
        if path.exists():
            file_time = datetime.datetime.fromtimestamp(path.stat().st_ctime)
            new_name = path.with_suffix(".log." + file_time.isoformat())
            path.rename(new_name)

    for n in range(0, args.runs):
        for build, logfile, dataset in zip_longest(
            args.builds, args.logfiles, args.datasets
        ):
            with open(logfile, "a") as log:

                def tee(text):
                    if text != ".\n":
                        print(text, end="")
                        log.write(text)
                        log.flush()

                client = Client(args)

                binary = os.path.join(build, "osrm-routed")
                proc = subprocess.Popen(
                    [binary, "-a", "CH", dataset],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                )
                tee(f'### {n} "{binary}" "{logfile}"\n')
                left = args.run_length
                ready = False
                random.seed(69)
                while left > 0 and proc.poll() is None:
                    if ready:
                        client.make_request()
                        left -= 1
                    text = proc.stdout.readline()
                    if not ready and "running" in text:
                        ready = True
                    tee(text)
                    tee(f"Distance: {client.distance}\n")
                    # log.write(text)
                proc.kill()
    report(args)


def make_csv(args):
    url = "https://overpass-api.de/api/interpreter?data=" + OVERPASS_QUERY.strip()

    with open(args.coordinates, "w") as coords:
        print("connecting to overpass ...")
        r = requests.get(url)
        r.raise_for_status()
        r.encoding = "utf-8"
        coords.write(r.text)
        print(f"wrote: {args.coordinates}")


def report(args):
    rows = list()
    index = list()
    for logfile in args.logfiles:
        with open(logfile) as log:
            for line in log:
                if m := re.search(r"^### (\d+) \"(.*?)\" \"(.*?)\"$", line):
                    index.append((int(m.group(1)), m.group(3)))
                    rows.append({"samples": 0, "time": 0.0, "distance": 0.0})
                if m := re.search(r"([.\d]+)ms", line):
                    rows[-1]["time"] += float(m.group(1))
                    rows[-1]["samples"] += 1
                if m := re.search(r"Distance: ([.\d]+)", line):
                    rows[-1]["distance"] += float(m.group(1))

    df = pd.DataFrame(
        rows, index=pd.MultiIndex.from_tuples(index, names=("run", "log"))
    )

    print(f"## RAW data - {datetime.datetime.now().isoformat()}\n```")
    print(df)
    print("```")

    def norm(series):
        return series / series.iloc[0]

    groupby = ["log"]

    agg = df.time.groupby(groupby, sort=False).agg(["median"])
    agg.insert(1, "time_norm", norm(agg["median"]), allow_duplicates=True)

    agg2 = df.distance.groupby(groupby, sort=False).agg(["median"])
    agg2.insert(1, "dist_norm", norm(agg2["median"]), allow_duplicates=True)

    agg = agg.merge(agg2, on="log")

    print("\n## Summary\n")

    agg = agg.reset_index(names=groupby)

    headers = ("log", "time (ms)", "norm", "distance", "norm")
    floatfmt = ("", ".2f", ".3f", ".0f", ".3f")
    print(
        tabulate.tabulate(
            agg, headers, tablefmt="github", floatfmt=floatfmt, showindex=False
        )
    )


def build_parser():
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    subparsers = parser.add_subparsers()

    parser_run = subparsers.add_parser("run", help="run the benchmark")
    parser_run.set_defaults(func=run)
    parser_run.add_argument(
        "--builds",
        nargs="+",
        metavar="PATH",
        help="The directories containing the osrm toolchains (path/to/build)",
    )
    parser_run.add_argument(
        "--datasets",
        nargs="+",
        metavar="PATH",
        help="Datasets to use or compare (path/to/dataset.osrm)",
    )
    parser_run.add_argument(
        "--coordinates",
        required=True,
        metavar="PATH",
        help="A CSV-file of coordinates",
    )
    parser_run.add_argument(
        "--logfiles",
        nargs="+",
        metavar="PATH",
        help="the test logs to write",
    )
    parser_run.add_argument(
        "--runs",
        type=int,
        help="How many times to run the comparison",
        default=10,
        metavar="NUM",
    )
    parser_run.add_argument(
        "--run-length",
        help="How many requests to make in each run",
        default=1000,
        metavar="NUM",
    )
    parser_run.add_argument(
        "--host",
        type=str,
        # "localhost" has abysmal performance on Windows
        default="http://127.0.0.1:5000",
        metavar="URL:PORT",
        help="Host URL (http://127.0.0.1:5000)",
    )

    parser_report = subparsers.add_parser("report", help="analyze logfiles")
    parser_report.set_defaults(func=report)
    parser_report.add_argument(
        "--logfiles",
        nargs="+",
        metavar="PATH",
        help="the test logs to analyze",
    )

    parser_csv = subparsers.add_parser("csv", help="download the CSV file")
    parser_csv.set_defaults(func=make_csv)
    parser_csv.add_argument(
        "--coordinates",
        required=True,
        metavar="PATH",
        help="write a CSV-file of coordinates",
    )
    return parser


def main():
    args = build_parser().parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
