#!/usr/bin/env python3
# Copyright © 2024 Rot127 <unisono@quyllur.org>
# SPDX-License-Identifier: BSD-3

import argparse
import logging as log
import json
import re
import sys
import subprocess as sp

from pathlib import Path

from autosync.Targets import TARGETS_LLVM_NAMING, TARGET_TO_DIR_NAME
from autosync.Helper import convert_loglevel, get_path


class LLVM_MC_Command:
    def __init__(self, cmd_line: str, mattr: str):
        self.cmd: str = ""
        self.opts: str = ""
        self.file: Path | None = None
        self.additional_mattr: str = mattr

        self.cmd, self.opts, self.file = self.parse_llvm_mc_line(cmd_line)
        if not (self.cmd and self.opts and self.file):
            log.warning(f"Could not parse llvm-mc command: {cmd_line}")
        elif not "--show-encoding" in self.cmd:
            self.cmd = re.sub("llvm-mc", "llvm-mc --show-encoding", self.cmd)
        elif not "--disassemble" in self.cmd:
            self.cmd = re.sub("llvm-mc", "llvm-mc --disassemble", self.cmd)

    def parse_llvm_mc_line(self, line: str) -> tuple[str, str, Path]:
        test_file_base_dir = str(get_path("{LLVM_LIT_TEST_DIR}").absolute())
        file = re.findall(rf"{test_file_base_dir}\S+", line)
        if not file:
            log.warning(f"llvm-mc command doesn't contain a file: {line}")
            return None, None, None
        test_file = file[0]
        cmd = re.sub(rf"{test_file}", "", line).strip()
        cmd = re.sub(r"\s+", " ", cmd)
        arch = re.finditer(r"(triple|arch)[=\s](\S+)", cmd)
        mattr = re.finditer(r"(mattr|mcpu)[=\s](\S+)", cmd)
        opts = ",".join([m.group(2) for m in arch]) if arch else ""
        if mattr:
            opts += "" if not opts else ","
            processed_attr = list()
            for m in mattr:
                attribute = m.group(2).strip("+")
                processed_attr.append(attribute)
            opts += ",".join(processed_attr)

        if "-riscv-no-aliases" in cmd:
            opts += " riscv-no-aliases"

        return cmd, opts, Path(test_file)

    def exec(self) -> sp.CompletedProcess:
        with open(self.file, "b+r") as f:
            content = f.read()
        if self.additional_mattr:
            # If mattr exists, patch it into the cmd
            if "mattr" in self.cmd:
                self.cmd = re.sub(
                    r"mattr[=\s]+", f"mattr={self.additional_mattr} -mattr=", self.cmd
                )
            else:
                self.cmd = re.sub(
                    r"llvm-mc", f"llvm-mc -mattr={self.additional_mattr}", self.cmd
                )

        log.debug(f"Run: {self.cmd}")
        result = sp.run(self.cmd.split(" "), input=content, capture_output=True)
        return result

    def get_opts_list(self) -> list[str]:
        opts = self.opts.strip().strip(",")
        opts = re.sub(r"[, ]+", ",", opts)
        return opts.split(",")

    def __str__(self) -> str:
        return f"{self.cmd} < {str(self.file.absolute())}"


class MCTest:
    """
    A single test. It can contain multiple decoded instruction for a given byte sequence.
    In general a MCTest always tests a sequence of instructions in a single .text segment.
    """

    def __init__(self, arch: str, opts: list[str], encoding: str, asm_text: str):
        self.arch = arch
        self.opts = opts
        self.encoding: list[str] = [encoding]
        self.asm_text: list[str] = [asm_text]

    def extend(self, encoding: str, asm_text: str):
        self.encoding.append(encoding)
        self.asm_text.append(asm_text)

    def get_legacy_mc_test_triple(self):
        """
        Returns the legacy triple for the old MC test files:
        <ARCH>, <MODE>, None
        Should only be used to generate fuzzing tests.
        """
        triple = "# "
        if self.arch.startswith("CS_ARCH"):
            triple += self.arch
        else:
            triple += f"CS_ARCH_{self.arch.upper()}"
        opts = "|".join([f'"{o}"' for o in self.opts if o.startswith("CS_MODE_")])
        if not opts:
            opts = "0"
        triple += f", {opts}, None"
        return triple

    def fuzz_test_str(self):
        old_mc_tcase = ""
        for enc, asm_text in zip(self.encoding, self.asm_text):
            if old_mc_tcase:
                old_mc_tcase += "\n"
            encoding = re.sub(r"[\[\]]", "", enc)
            encoding = encoding.strip()
            encoding = re.sub(r"[\s,]+", ",", encoding)
            old_mc_tcase += f"{encoding} == {asm_text}"
        return old_mc_tcase

    def __str__(self):
        encoding = ",".join(self.encoding)
        encoding = re.sub(r"[\[\]]", "", encoding)
        encoding = encoding.strip()
        encoding = re.sub(r"[\s,]+", ", ", encoding)
        yaml_tc = (
            "  -\n"
            "    input:\n"
            "      bytes: [ <ENCODING> ]\n"
            '      arch: "<ARCH>"\n'
            "      options: [ <OPTIONS> ]\n"
            "    expected:\n"
            "      insns:\n"
        )
        template = "        -\n          asm_text: <ASM_TEXT>\n"
        insn_cases = ""
        for text in self.asm_text:
            insn_cases += template.replace("<ASM_TEXT>", f'"{text}"')

        yaml_tc = yaml_tc.replace("<ENCODING>", encoding)
        yaml_tc = yaml_tc.replace("<ARCH>", f"CS_ARCH_{self.arch.upper()}")
        yaml_tc = yaml_tc.replace("<OPTIONS>", ", ".join([f'"{o}"' for o in self.opts]))
        yaml_tc += insn_cases
        return yaml_tc


class TestFile:
    def __init__(
        self,
        arch: str,
        file_path: Path,
        opts: list[str] | None,
        mc_cmd: LLVM_MC_Command,
        unified_test_cases: bool,
    ):
        self.arch: str = arch
        self.file_path: Path = file_path
        self.opts: list[str] = list() if not opts else opts
        self.mc_cmd: LLVM_MC_Command = mc_cmd
        # Indexed by .text section count
        self.tests: dict[int : list[MCTest]] = dict()

        self.init_tests(unified_test_cases)

    def init_tests(self, unified_test_cases: bool):
        mc_output = self.mc_cmd.exec()
        if mc_output.stderr and not mc_output.stdout:
            # We can still continue. We just ignore the failed cases.
            log.debug(f"llvm-mc cmd stderr: {mc_output.stderr}")
        log.debug(f"llvm-mc result: {mc_output}")
        text_section = 0  # Counts the .text sections
        asm_pat = f"(?P<asm_text>.+)"
        enc_pat = r"(\[?(?P<full_enc_string>(?P<enc_bytes>((0x[a-fA-F0-9]{1,2}[, ]{0,2}))+)[^, ]?)\]?)"

        dups = []
        for line in mc_output.stdout.splitlines():
            line = line.decode("utf8")
            if ".text" in line:
                text_section += 1
                continue
            match = re.search(
                rf"^\s*{asm_pat}\s*(#|//|@|!|;)\s*encoding:\s*{enc_pat}", line
            )
            if not match:
                continue
            full_enc_string = match.group("full_enc_string")
            if not re.search(r"0x[a-fA-F0-9]{1,2}$", full_enc_string[:-1]):
                log.debug(f"Ignore because symbol injection is needed: {line}")
                # The encoding string contains symbol information of the form:
                # [0xc0,0xe0,A,A,A... or similar. We ignore these for now.
                continue
            enc_bytes = match.group("enc_bytes").strip()
            asm_text = match.group("asm_text").strip()
            asm_text = re.sub(r"\t+", " ", asm_text)
            asm_text = asm_text.strip()
            if not self.valid_byte_seq(enc_bytes):
                log.warning(
                    f"ignoring invalid byte stream {self.mc_cmd.file} for arch {self.arch}"
                )
                continue

            if (enc_bytes + asm_text) in dups:
                continue

            if self.arch == "RISCV" and ".insn" in asm_text:
                log.warning(
                    f"ignoring .insn directive {asm_text}, unsupported for arch {self.arch}"
                )
                continue

            dups.append(enc_bytes + asm_text)
            if text_section in self.tests:
                if unified_test_cases:
                    self.tests[text_section][0].extend(enc_bytes, asm_text)
                else:
                    self.tests[text_section].append(
                        MCTest(self.arch, self.opts, enc_bytes, asm_text)
                    )
            else:
                self.tests[text_section] = [
                    MCTest(self.arch, self.opts, enc_bytes, asm_text)
                ]

    def has_tests(self) -> bool:
        return len(self.tests) != 0

    def get_cs_testfile_content(self, only_tests: bool) -> str:
        content = "\n" if only_tests else "test_cases:\n"
        for tl in self.tests.values():
            content += "\n".join([str(t) for t in tl])
        return content

    def get_fuzz_test_file_content(self, only_tests: bool) -> str:
        content = ""
        for tl in self.tests.values():
            if not content:
                content = (
                    "\n" if only_tests else tl[0].get_legacy_mc_test_triple() + "\n"
                )
            content += "\n".join([t.fuzz_test_str() for t in tl])
        return content

    def num_test_cases(self) -> int:
        return len(self.tests)

    def valid_byte_seq(self, enc_bytes):
        match self.arch:
            case "AArch64":
                # It always needs 4 bytes.
                # Otherwise it is likely a reloc or symbol test
                return enc_bytes.count("0x") == 4
            case "RISCV":
                # RISCV can allow arbitrary lengths up to 192-bit and beyond
                # but the vast majority is 16 and 32 bit, we also allow 48 and 64 for good measure
                return enc_bytes.count("0x") in {2, 4, 6, 8}
            case _:
                return True

    def get_multi_mode_filename(self) -> Path:
        filename = self.file_path.stem
        parent = self.file_path.parent
        prefix_less_opts = [re.sub(r"CS_(OPT|MODE)_", "", o).lower() for o in self.opts]
        detailed_name = f"{filename}_{'_'.join(prefix_less_opts)}.txt"
        detailed_name = re.sub(r"[+-]", "_", detailed_name)
        out_path = parent.joinpath(detailed_name)
        return Path(out_path)

    def get_simple_filename(self) -> Path:
        return self.file_path

    def __lt__(self, other) -> bool:
        return str(self.file_path) < str(other.file_path)


def exists_and_is_dir(x):
    return x.exists() and x.is_dir()


class MCUpdater:
    """
    The MCUpdater parses all test files of the LLVM MC regression tests.
    Each of those LLVM files can contain several llvm-mc commands to run on the same file.
    Mostly this is done to test the same file with different CPU features enabled.
    So it can test different flavors of assembly etc.

    In Capstone all modules enable always all CPU features (even if this is not
    possible in reality).
    Due to this we always parse all llvm-mc commands run on a test file, generate a TestFile
    object for each of it, but only write the last one of them to disk.
    Once https://github.com/capstone-engine/capstone/issues/1992 is resolved, we can
    write all variants of a test file to disk.

    This is already implemented and tested with multi_mode = True.
    """

    def __init__(
        self,
        arch: str,
        mc_dir: Path,
        excluded: list[str] | None,
        included: list[str] | None,
        unified_test_cases: bool,
        multi_mode: bool = False,
    ):
        self.symbolic_links = list()
        self.arch = arch
        self.arch_dir_name = TARGET_TO_DIR_NAME[self.arch]
        self.test_dir_link_prefix = f"test_dir_{arch}_"
        self.mc_dir = mc_dir
        self.excluded = excluded if excluded else list()
        self.included = included if included else list()
        self.test_files: list[TestFile] = list()
        self.unified_test_cases = unified_test_cases
        with open(get_path("{MCUPDATER_CONFIG_FILE}")) as f:
            self.conf = json.loads(f.read())
        # Additional mattr passed to llvm-mc
        self.mattr: str = (
            ",".join(self.conf["additional_mattr"][self.arch])
            if self.arch in self.conf["additional_mattr"]
            else ""
        )
        # A list of options which are always added.
        self.mandatory_options: list[str] = (
            self.conf["mandatory_options"][self.arch]
            if self.arch in self.conf["mandatory_options"]
            else list()
        )
        self.default_endianess: str = (
            self.conf["default_endianess"][self.arch]
            if self.arch in self.conf["default_endianess"]
            else ""
        )
        self.remove_options: str = (
            self.conf["remove_options"][self.arch]
            if self.arch in self.conf["remove_options"]
            else list()
        )
        self.remove_options = [x.lower() for x in self.remove_options]
        self.replace_option_map: dict = (
            self.conf["replace_option_map"][self.arch]
            if self.arch in self.conf["replace_option_map"]
            else {}
        )
        self.replace_option_map = {
            k.lower(): v
            for k, v in self.replace_option_map.items()
            if k.lower not in self.remove_options
        }
        self.multi_mode = multi_mode

    def check_prerequisites(self, paths):
        if all(not exists_and_is_dir(path) for path in paths):
            raise ValueError(
                f"'{paths}' does not exits or is not a directory. Cannot generate tests from there."
            )
        llvm_lit_cfg = get_path("{LLVM_LIT_TEST_DIR}")
        if not llvm_lit_cfg.exists():
            raise ValueError(
                f"Could not find '{llvm_lit_cfg}'. Check {{LLVM_LIT_TEST_DIR}} in path_vars.json."
            )

    def write_to_build_dir(self, fuzzer_tests: bool = False):
        no_tests_file = 0
        file_cnt = 0
        test_cnt = 0
        overwritten = 0
        files_written = set()
        for test in sorted(self.test_files):
            if not test.has_tests():
                no_tests_file += 1
                continue
            file_cnt += 1
            test_cnt += test.num_test_cases()

            if self.multi_mode:
                rel_path = str(
                    test.get_multi_mode_filename().relative_to(
                        get_path("{LLVM_LIT_TEST_DIR}")
                    )
                )
            else:
                rel_path = str(
                    test.get_simple_filename().relative_to(
                        get_path("{LLVM_LIT_TEST_DIR}")
                    )
                )

            filename = re.sub(rf"{self.test_dir_link_prefix}\d+", ".", rel_path)
            if fuzzer_tests:
                filename = get_path("{MCUPDATER_OUT_FUZZ_DIR}").joinpath(
                    f"{filename}.cs"
                )
            else:
                filename = get_path("{MCUPDATER_OUT_DIR}").joinpath(f"{filename}.yaml")

            if filename in files_written:
                write_mode = "a"
            else:
                write_mode = "w+"
            filename.parent.mkdir(parents=True, exist_ok=True)
            if self.multi_mode and filename.exists():
                log.warning(
                    f"The following file exists already: {filename}. This indicates a blind spot in testing."
                )
                overwritten += 1
            elif not self.multi_mode and filename.exists():
                log.debug(f"Overwrite: {filename}")
                overwritten += 1
            with open(filename, write_mode) as f:
                if fuzzer_tests:
                    content = test.get_fuzz_test_file_content(
                        only_tests=(write_mode == "a")
                    )
                else:
                    content = test.get_cs_testfile_content(
                        only_tests=(write_mode == "a")
                    )
                f.write(content)
                log.debug(f"Write {filename}")
            files_written.add(filename)
        print()
        log.info(
            f"Got {len(self.test_files)} {'fuzzing ' if fuzzer_tests else ''}test files.\n"
            f"\t\tProcessed {file_cnt} files with {test_cnt} test cases.\n"
            f"\t\tIgnored {no_tests_file} without tests.\n"
            f"\t\tGenerated {len(files_written)} files"
        )
        if overwritten > 0:
            log.warning(
                f"Overwrote {overwritten} test files with the same name.\n"
                f"These files contain instructions of several different cpu features.\n"
                f"You have to use multi-mode to write them into distinct files.\n"
                f"The current setting will only keep the last one written.\n"
                f"See also: https://github.com/capstone-engine/capstone/issues/1992\n"
                "If you already used multi-mode (default = yes), there might be a blind spot in testing."
            )

    def build_test_options(self, options):
        new_options = [] + self.mandatory_options
        for opt in options:
            opt = opt.lower()
            if opt in self.remove_options:
                continue
            elif opt in self.replace_option_map:
                new_options.extend(self.replace_option_map[opt])
            else:
                new_options.append(opt)
        if (
            not any(
                [
                    True
                    for x in new_options
                    if x in ["CS_MODE_BIG_ENDIAN", "CS_MODE_LITTLE_ENDIAN"]
                ]
            )
            and self.default_endianess
        ):
            new_options.append(self.default_endianess)
        return new_options

    def build_test_files(self, mc_cmds: list[LLVM_MC_Command]) -> list[TestFile]:
        log.info("Build TestFile objects")
        test_files = list()
        n_all = len(mc_cmds)
        for i, mcc in enumerate(mc_cmds):
            print(f"{i + 1}/{n_all} {mcc.file.name}", flush=True, end="\r")
            opts = self.build_test_options(mcc.get_opts_list())
            test_files.append(
                TestFile(
                    self.arch,
                    mcc.file,
                    opts,
                    mcc,
                    self.unified_test_cases,
                )
            )
        return test_files

    def run_llvm_lit(self, paths: list[Path]) -> list[LLVM_MC_Command]:
        """
        Calls llvm-lit with the given paths to the tests.
        It parses the llvm-lit commands to LLVM_MC_Commands.
        """
        lit_cfg_dir = get_path("{LLVM_LIT_TEST_DIR}")
        llvm_lit_cfg = str(lit_cfg_dir.absolute())
        args = ["lit", "-v", "-a", llvm_lit_cfg]
        for i, p in enumerate(paths):
            slink = lit_cfg_dir.joinpath(f"{self.test_dir_link_prefix}{i}")
            self.symbolic_links.append(slink)
            log.debug(f"Create link: {slink} -> {p}")
            try:
                slink.symlink_to(p, target_is_directory=True)
            except FileExistsError as e:
                print("Failed: Link existed. Please delete it")
                raise e

        log.info(f"Run lit: {' '.join(args)}")
        cmds = sp.run(args, capture_output=True)
        if cmds.stderr:
            raise ValueError(f"llvm-lit failed with {cmds.stderr}")
        return self.extract_llvm_mc_cmds(cmds.stdout.decode("utf8"))

    def extract_llvm_mc_cmds(self, cmds: str) -> list[LLVM_MC_Command]:
        log.debug("Parsing llvm-mc commands")
        # Get only the RUN lines which have a show-encoding set.
        cmd_lines = cmds.splitlines()
        log.debug(f"NO FILTER: {cmd_lines}")
        matches = list(
            filter(
                lambda l: (
                    l
                    if re.search(r"^RUN.+(show-encoding|disassemble)[^|]+", l)
                    else None
                ),
                cmd_lines,
            )
        )
        log.debug(f"FILTER RUN: {' '.join(matches)}")
        # Don't add tests which are allowed to fail
        matches = list(
            filter(lambda m: None if re.search(r"not\s+llvm-mc", m) else m, matches)
        )
        log.debug(f"FILTER not llvm-mc: {' '.join(matches)}")
        # Skip object file tests
        matches = list(
            filter(lambda m: None if re.search(r"filetype=obj", m) else m, matches)
        )
        log.debug(f"FILTER filetype=obj-mc: {' '.join(matches)}")
        # Skip any relocation related tests.
        matches = filter(lambda m: None if re.search(r"reloc", m) else m, matches)
        # Remove 'RUN: at ...' prefix
        matches = map(lambda m: re.sub(r"^RUN: at line \d+: ", "", m), matches)
        # Remove redirection
        matches = map(lambda m: re.sub(r"\d>&\d", "", m), matches)
        # Remove unused arguments
        matches = map(lambda m: re.sub(r"-o\s?-", "", m), matches)
        # Remove redirection of stderr to a file
        matches = map(lambda m: re.sub(r"2>\s?\S+", "", m), matches)
        # Remove piping to FileCheck
        matches = map(lambda m: re.sub(r"\|\s*FileCheck\s+.+", "", m), matches)
        # Remove input stream
        matches = map(lambda m: re.sub(r"\s+<", "", m), matches)

        all_cmds = list()
        for match in matches:
            if self.included and not any(
                re.search(x, match) is not None for x in self.included
            ):
                continue
            if any(re.search(x, match) is not None for x in self.excluded):
                continue
            llvm_mc_cmd = LLVM_MC_Command(match, self.mattr)
            if not llvm_mc_cmd.cmd:
                # Invalid
                continue
            all_cmds.append(llvm_mc_cmd)
            log.debug(f"Added: {llvm_mc_cmd}")
        log.debug(f"Extracted {len(all_cmds)} llvm-mc commands")
        return all_cmds

    def gen_all(self):
        log.info("Check prerequisites")
        test_paths = list()
        if self.arch in self.conf["use_assembly_tests"]:
            log.info(f"Add assembly tests for {self.arch}")
            test_paths.append(self.mc_dir.joinpath(self.arch))
        if self.arch not in self.conf["exclude_disassembly_tests"]:
            log.info(f"Add disassembly tests for {self.arch}")
            disas_tests = self.mc_dir.joinpath(f"Disassembler/{self.arch_dir_name}")
            test_paths.append(disas_tests)
        self.check_prerequisites(test_paths)
        log.info("Generate MC regression tests")
        llvm_mc_cmds = self.run_llvm_lit(
            [path for path in test_paths if exists_and_is_dir(path)]
        )
        log.info(f"Got {len(llvm_mc_cmds)} llvm-mc commands to run")
        self.test_files = self.build_test_files(llvm_mc_cmds)
        for slink in self.symbolic_links:
            log.debug(f"Unlink {slink}")
            slink.unlink()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="Test file updater",
        description="Synchronizes test files with LLVM",
    )
    parser.add_argument(
        "-d",
        dest="mc_dir",
        help=f"Path to the LLVM MC test files. Default: {get_path('{LLVM_MC_TEST_DIR}')}",
        default=get_path("{LLVM_MC_TEST_DIR}"),
        type=Path,
    )
    parser.add_argument(
        "-a",
        dest="arch",
        help="Name of architecture to update.",
        choices=TARGETS_LLVM_NAMING,
        required=True,
    )
    parser.add_argument(
        "-e",
        dest="excluded_files",
        metavar="filename",
        nargs="+",
        help="File names to exclude from update (can be a regex pattern).",
    )
    parser.add_argument(
        "-i",
        dest="included_files",
        metavar="filename",
        nargs="+",
        help="Specific list of file names to update (can be a regex pattern).",
    )
    parser.add_argument(
        "-u",
        dest="unified_tests",
        action="store_true",
        default=False,
        help="If set, all instructions of a text segment will decoded and tested at once. Should be set, if instructions depend on each other.",
    )
    parser.add_argument(
        "-v",
        dest="verbosity",
        help="Verbosity of the log messages.",
        choices=["debug", "info", "warning", "fatal"],
        default="info",
    )
    arguments = parser.parse_args()
    return arguments


if __name__ == "__main__":
    args = parse_args()
    log.basicConfig(
        level=convert_loglevel(args.verbosity),
        stream=sys.stdout,
        format="%(levelname)-5s - %(message)s",
        force=True,
    )

    MCUpdater(
        args.arch,
        args.mc_dir,
        args.excluded_files,
        args.included_files,
        args.unified_tests,
        True,
    ).gen_all()
