#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0

"""
Toeplitz Rx hashing test:
 - rxhash (the hash value calculation itself);
 - RSS mapping from rxhash to rx queue;
 - RPS mapping from rxhash to cpu.
"""

import glob
import os
import socket
from lib.py import ksft_run, ksft_exit, ksft_pr
from lib.py import NetDrvEpEnv, EthtoolFamily, NetdevFamily
from lib.py import cmd, bkg, rand_port, defer
from lib.py import ksft_in
from lib.py import ksft_variants, KsftNamedVariant, KsftSkipEx, KsftFailEx

# "define" for the ID of the Toeplitz hash function
ETH_RSS_HASH_TOP = 1
# Must match RPS_MAX_CPUS in toeplitz.c
RPS_MAX_CPUS = 16


def _check_rps_and_rfs_not_configured(cfg):
    """Verify that RPS is not already configured."""

    for rps_file in glob.glob(f"/sys/class/net/{cfg.ifname}/queues/rx-*/rps_cpus"):
        with open(rps_file, "r", encoding="utf-8") as fp:
            val = fp.read().strip()
            if set(val) - {"0", ","}:
                raise KsftSkipEx(f"RPS already configured on {rps_file}: {val}")

    rfs_file = "/proc/sys/net/core/rps_sock_flow_entries"
    with open(rfs_file, "r", encoding="utf-8") as fp:
        val = fp.read().strip()
        if val != "0":
            raise KsftSkipEx(f"RFS already configured {rfs_file}: {val}")


def _get_cpu_for_irq(irq):
    with open(f"/proc/irq/{irq}/smp_affinity_list", "r",
              encoding="utf-8") as fp:
        data = fp.read().strip()
        if "," in data or "-" in data:
            raise KsftFailEx(f"IRQ{irq} is not mapped to a single core: {data}")
        return int(data)


def _get_irq_cpus(cfg):
    """
    Read the list of IRQs for the device Rx queues.
    """
    queues = cfg.netnl.queue_get({"ifindex": cfg.ifindex}, dump=True)
    napis = cfg.netnl.napi_get({"ifindex": cfg.ifindex}, dump=True)

    # Remap into ID-based dicts
    napis = {n["id"]: n for n in napis}
    queues = {f"{q['type']}{q['id']}": q for q in queues}

    cpus = []
    for rx in range(9999):
        name = f"rx{rx}"
        if name not in queues:
            break
        cpus.append(_get_cpu_for_irq(napis[queues[name]["napi-id"]]["irq"]))

    return cpus


def _get_unused_rps_cpus(cfg, count=2):
    """
    Get CPUs that are not used by Rx queues for RPS.
    Returns a list of at least 'count' CPU numbers within
    the RPS_MAX_CPUS supported range.
    """

    # Get CPUs used by Rx queues
    rx_cpus = set(_get_irq_cpus(cfg))

    # Get total number of CPUs, capped by RPS_MAX_CPUS
    num_cpus = min(os.cpu_count(), RPS_MAX_CPUS)

    # Find unused CPUs
    unused_cpus = [cpu for cpu in range(num_cpus) if cpu not in rx_cpus]

    if len(unused_cpus) < count:
        raise KsftSkipEx(f"Need at least {count} CPUs in range 0..{num_cpus - 1} not used by Rx queues, found {len(unused_cpus)}")

    return unused_cpus[:count]


def _configure_rps(cfg, rps_cpus):
    """Configure RPS for all Rx queues."""

    mask = 0
    for cpu in rps_cpus:
        mask |= (1 << cpu)

    mask = hex(mask)

    # Set RPS bitmap for all rx queues
    for rps_file in glob.glob(f"/sys/class/net/{cfg.ifname}/queues/rx-*/rps_cpus"):
        with open(rps_file, "w", encoding="utf-8") as fp:
            # sysfs expects hex without '0x' prefix, toeplitz.c needs the prefix
            fp.write(mask[2:])

    return mask


def _send_traffic(cfg, proto_flag, ipver, port):
    """Send 20 packets of requested type."""

    # Determine protocol and IP version for socat
    if proto_flag == "-u":
        proto = "UDP"
    else:
        proto = "TCP"

    baddr = f"[{cfg.addr_v['6']}]" if ipver == "6" else cfg.addr_v["4"]

    # Run socat in a loop to send traffic periodically
    # Use sh -c with a loop similar to toeplitz_client.sh
    socat_cmd = f"""
    for i in `seq 20`; do
        echo "msg $i" | socat -{ipver} -t 0.1 - {proto}:{baddr}:{port};
        sleep 0.001;
    done
    """

    cmd(socat_cmd, shell=True, host=cfg.remote)


def _test_variants():
    for grp in ["", "rss", "rps"]:
        for l4 in ["tcp", "udp"]:
            for l3 in ["4", "6"]:
                name = f"{l4}_ipv{l3}"
                if grp:
                    name = f"{grp}_{name}"
                yield KsftNamedVariant(name, "-" + l4[0], l3, grp)


@ksft_variants(_test_variants())
def test(cfg, proto_flag, ipver, grp):
    """Run a single toeplitz test."""

    cfg.require_ipver(ipver)

    # Check that rxhash is enabled
    ksft_in("receive-hashing: on", cmd(f"ethtool -k {cfg.ifname}").stdout)

    rss = cfg.ethnl.rss_get({"header": {"dev-index": cfg.ifindex}})
    # Make sure NIC is configured to use Toeplitz hash, and no key xfrm.
    if rss.get('hfunc') != ETH_RSS_HASH_TOP or rss.get('input-xfrm'):
        cfg.ethnl.rss_set({"header": {"dev-index": cfg.ifindex},
                           "hfunc": ETH_RSS_HASH_TOP,
                           "input-xfrm": {}})
        defer(cfg.ethnl.rss_set, {"header": {"dev-index": cfg.ifindex},
                                  "hfunc": rss.get('hfunc'),
                                  "input-xfrm": rss.get('input-xfrm', {})
                                  })

    port = rand_port(socket.SOCK_DGRAM)

    toeplitz_path = cfg.test_dir / "toeplitz"
    rx_cmd = [
        str(toeplitz_path),
        "-" + ipver,
        proto_flag,
        "-d", str(port),
        "-i", cfg.ifname,
        "-T", "4000",
        "-s",
        "-v"
    ]

    if grp:
        _check_rps_and_rfs_not_configured(cfg)
    if grp == "rss":
        irq_cpus = ",".join([str(x) for x in _get_irq_cpus(cfg)])
        rx_cmd += ["-C", irq_cpus]
        ksft_pr(f"RSS using CPUs: {irq_cpus}")
    elif grp == "rps":
        # Get CPUs not used by Rx queues and configure them for RPS
        rps_cpus = _get_unused_rps_cpus(cfg, count=2)
        rps_mask = _configure_rps(cfg, rps_cpus)
        defer(_configure_rps, cfg, [])
        rx_cmd += ["-r", rps_mask]
        ksft_pr(f"RPS using CPUs: {rps_cpus}, mask: {rps_mask}")

    # Run rx in background, it will exit once it has seen enough packets
    with bkg(" ".join(rx_cmd), ksft_ready=True, exit_wait=True) as rx_proc:
        while rx_proc.proc.poll() is None:
            _send_traffic(cfg, proto_flag, ipver, port)

    # Check rx result
    ksft_pr("Receiver output:")
    ksft_pr(rx_proc.stdout.strip().replace('\n', '\n# '))
    if rx_proc.stderr:
        ksft_pr(rx_proc.stderr.strip().replace('\n', '\n# '))


def main() -> None:
    """Ksft boilerplate main."""

    with NetDrvEpEnv(__file__) as cfg:
        cfg.ethnl = EthtoolFamily()
        cfg.netnl = NetdevFamily()
        ksft_run(cases=[test], args=(cfg,))
    ksft_exit()


if __name__ == "__main__":
    main()
