#!/usr/bin/env python3
#
# Copyright © 2017 Red Hat, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice (including the next
# paragraph) shall be included in all copies or substantial portions of the
# Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
# Device data verification script
#

import argparse
import configparser
import pathlib
import re
import sys
import traceback


def check_match_str(string: str):
    bustypes = ["usb", "bluetooth"]

    matches = string.split(";")
    for match in matches:
        if not match:  # empty string if trailing ;
            continue

        parts = match.split(":")
        assert len(parts) == 3
        assert parts[0] in bustypes
        vid = parts[1]
        assert vid == f"{int(vid, 16):04x}"
        pid = parts[2]
        assert pid == f"{int(pid, 16):04x}"


def check_devicetype_str(string):
    permitted_types = ["mouse", "keyboard", "other"]
    assert string in permitted_types


def check_section_device(section: configparser.SectionProxy):
    required_keys = ["Name", "Driver", "DeviceMatch", "DeviceType"]

    for key in section:
        assert key in required_keys

    for r in required_keys:
        assert r in section

    check_devicetype_str(section["DeviceType"])

    check_match_str(section["DeviceMatch"])


def check_dpi_range_str(string: str):
    m = re.search("^([0-9]+):([0-9]+)@([0-9.]+)$", string)
    assert m is not None
    min = int(m.group(1))
    max = int(m.group(2))
    steps = float(m.group(3))

    assert min >= 0 and min <= 400
    assert max >= 2000 and max <= 36000
    assert steps > 0 and steps <= 100

    if int(steps) == steps:
        steps = int(steps)

    assert string == f"{min}:{max}@{steps}"


def check_dpi_list_str(string: str):
    entries = string.split(";")
    # Remove possible empty last entry if trailing with a ;
    if not entries[len(entries) - 1]:
        entries = entries[:-1]

    for idx, entry in enumerate(entries):
        dpi = int(entry)
        assert dpi >= 0 and dpi <= 12000
        if idx > 0:
            prev = entries[idx - 1]
            prev_dpi = int(prev)
            assert dpi > prev_dpi


def check_profile_type_str(string: str):
    types = ["G9", "G500", "G700"]
    assert string in types


def check_section_asus(section: configparser.SectionProxy):
    permitted_keys = (
        "ButtonMapping",
        "Buttons",
        "DpiRange",
        "Dpis",
        "Leds",
        "Profiles",
        "Quirks",
        "Wireless",
    )
    for key in section:
        assert key in permitted_keys

    try:
        check_dpi_range_str(section["DpiRange"])
    except KeyError:
        # No such section - not an error.
        pass

    try:
        quirks = (
            "DOUBLE_DPI",
            "STRIX_PROFILE",
        )
        for quirk in section["Quirks"].split(";"):
            assert quirk in quirks
    except KeyError:
        # No such section - not an error.
        pass


def check_section_hidpp10(section: configparser.SectionProxy):
    permitted = [
        "Profiles",
        "ProfileType",
        "DpiRange",
        "DpiList",
        "DeviceIndex",
        "Leds",
    ]
    for key in section:
        assert key in permitted

    try:
        nprofiles = int(section["Profiles"])
        # 10 is arbitrarily chosen
        assert nprofiles > 0 and nprofiles < 10
    except KeyError:
        # No such section - not an error.
        pass

    try:
        index = int(section["DeviceIndex"], 16)
        assert index > 0 and index <= 0xFF
    except KeyError:
        # No such section - not an error.
        pass

    try:
        check_dpi_range_str(section["DpiRange"])
        assert "DpiList" not in section.keys()
    except KeyError:
        # No such section - not an error.
        pass

    try:
        check_dpi_list_str(section["DpiList"])
        assert "DpiRange" not in section.keys()
    except KeyError:
        # No such section - not an error.
        pass

    try:
        check_profile_type_str(section["ProfileType"])
    except KeyError:
        # No such section - not an error.
        pass

    try:
        leds = int(section["Leds"])
        # 10 is arbitrarily chosen
        assert leds > 0 and leds < 10
    except KeyError:
        # No such section - not an error.
        pass


def check_section_hidpp20(section: configparser.SectionProxy):
    permitted = ["Buttons", "DeviceIndex", "Leds", "ReportRate", "Quirk"]
    for key in section:
        assert key in permitted

    try:
        index = int(section["DeviceIndex"], 16)
        assert index > 0 and index <= 0xFF
    except KeyError:
        # No such section - not an error.
        pass


def check_section_steelseries(section: configparser.SectionProxy):
    permitted_keys = (
        "Buttons",
        "DeviceVersion",
        "DpiList",
        "DpiRange",
        "Leds",
        "MacroLength",
        "Quirk",
    )
    for key in section:
        assert key in permitted_keys

    try:
        check_dpi_list_str(section["DpiList"])
        assert "DpiRange" not in section.keys()
    except KeyError:
        # No such section - not an error.
        pass

    try:
        check_dpi_range_str(section["DpiRange"])
        assert "DpiList" not in section.keys()
    except KeyError:
        # No such section - not an error.
        pass

    try:
        quirks = ("Rival100", "SenseiRAW")
        assert section["Quirk"] in quirks
    except KeyError:
        # No such section - not an error.
        pass


def check_section_driver(driver: str, section: configparser.SectionProxy):
    if driver == "asus":
        check_section_asus(section)
        return

    if driver == "hidpp10":
        check_section_hidpp10(section)
        return

    if driver == "hidpp20":
        check_section_hidpp20(section)
        return

    if driver == "steelseries":
        check_section_steelseries(section)
        return

    raise ValueError(f"Unsupported driver section {driver}")


def validate_data_file_name(path: str):
    # Matching any of the characters in the regular expression will throw an
    # error. Currently only tests the square brackets [], parentheses, and curly
    # braces.
    illegal_characters_regex = "([\\[\\]\\{\\}\\(\\)])"
    found_characters = re.findall(illegal_characters_regex, path)
    if found_characters:
        raise ValueError(
            "data file name '{}' contains illegal characters: '{}'".format(
                path, "".join(found_characters)
            )
        )


SINOWEALTH_FW_VERSION_LEN = 4
SINOWEALTH_DEVICE_SECTION_PREFIX = "Driver/sinowealth/devices/"
SINOWEALTH_REQUIRED_KEYS = (
    "DeviceName",
    "LedType",
)
SINOWEALTH_PERMITTED_KEYS = (
    *SINOWEALTH_REQUIRED_KEYS,
    "Buttons",
    "Profiles",
    "SensorType",
)


def parse_data_file(path: str):
    print(f"Parsing file {path}")
    data = configparser.ConfigParser(strict=True)
    # Don't convert to lowercase
    data.optionxform = lambda option: option
    data.read(path)

    assert "Device" in data.sections()
    check_section_device(data["Device"])

    driver = data["Device"]["Driver"]
    driver_section = f"Driver/{driver}"

    permitted_sections = ["Device", driver_section]
    # The sinowealth driver uses non-static section names in device files as it
    # uses a single device file for several actual devices. See the example
    # device file for details.
    if driver == "sinowealth":
        for device_section_name in data.sections():
            if not device_section_name.startswith(SINOWEALTH_DEVICE_SECTION_PREFIX):
                continue
            fw_version = device_section_name[len(SINOWEALTH_DEVICE_SECTION_PREFIX) :]
            assert len(fw_version) == SINOWEALTH_FW_VERSION_LEN

            device_section = data[device_section_name]
            for key in SINOWEALTH_REQUIRED_KEYS:
                assert key in device_section
            for key in device_section:
                assert key in SINOWEALTH_PERMITTED_KEYS
    else:
        for s in data.sections():
            assert s in permitted_sections

    if data.has_section(driver_section):
        check_section_driver(driver, data[driver_section])


def main() -> None:
    is_error = False

    parser = argparse.ArgumentParser(description="Device data-file checker")
    parser.add_argument("directory")
    args = parser.parse_args()
    for path in pathlib.Path(args.directory).glob("*.device"):
        path_str = str(path)
        try:
            validate_data_file_name(path_str)
            parse_data_file(path_str)
        except Exception:
            is_error = True
            traceback.print_exc(file=sys.stdout)

    if is_error:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
