#!/usr/bin/env python3
#
# SPDX-FileCopyrightText: 2017-2026 Sébastien Helleu <flashcode@flashtux.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#

"""Test script generator for WeeChat.

Build source of scripts in all languages to test the scripting API.

This script can be run in WeeChat or as a standalone script
(during automatic tests, it is loaded as a WeeChat script).

It uses the following scripts:
- unparse.py: convert Python code to other languages (including Python itself)
- testapi.py: the WeeChat scripting API tests
"""

# ruff: noqa: T201,UP006,UP007,UP035

import argparse
import ast
import datetime
import inspect
import io
import os
import sys
import traceback
from pathlib import Path
from typing import Tuple, Type, Union

sys.dont_write_bytecode = True

SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.append(str(SCRIPT_DIR))
from unparse import (  # noqa: E402
    Unparse,
    UnparseGuile,
    UnparseJavaScript,
    UnparseLua,
    UnparsePerl,
    UnparsePhp,
    UnparsePython,
    UnparseRuby,
    UnparseTcl,
)

RUNNING_IN_WEECHAT = True
try:
    import weechat
except ImportError:
    RUNNING_IN_WEECHAT = False


SCRIPT_NAME = "testapigen"
SCRIPT_AUTHOR = "Sébastien Helleu <flashcode@flashtux.org>"
SCRIPT_VERSION = "0.1"
SCRIPT_LICENSE = "GPL3"
SCRIPT_DESC = "Generate scripting API test scripts"

SCRIPT_COMMAND = "testapigen"


class WeechatScript:
    """A generic WeeChat script.

    This class must NOT be instantiated directly, use subclasses instead:
    PythonScript, PerlScript, ...
    """

    def __init__(
        self,
        unparse_class: Type[Unparse],
        tree: ast.AST,
        source_script: str,
        output_dir: str,
        language: str,
        extension: str,
        comment_char: str = "#",
        weechat_module: str = "weechat",
    ) -> None:
        """Initialize a WeeChat script generator."""
        self.unparse_class = unparse_class
        self.tree = tree
        self.source_script = os.path.realpath(source_script)
        self.output_dir = os.path.realpath(output_dir)
        self.language = language
        self.extension = extension
        self.script_name = f"weechat_testapi.{extension}"
        self.script_path = Path(self.output_dir) / self.script_name
        self.comment_char = comment_char
        self.weechat_module = weechat_module
        self.update_tree()

    def comment(self, string: str) -> str:
        """Get a commented line."""
        return f"{self.comment_char} {string}"

    def update_tree(self) -> None:
        """Make changes in AST tree."""
        functions = {
            "prnt": "print",
            "prnt_date_tags": "print_date_tags",
            "prnt_datetime_tags": "print_datetime_tags",
            "prnt_y": "print_y",
            "prnt_y_date_tags": "print_y_date_tags",
            "prnt_y_datetime_tags": "print_y_datetime_tags",
        }
        tests_count = 0
        for node in ast.walk(self.tree):
            # rename some API functions
            if (
                self.language != "python"
                and isinstance(node, ast.Call)
                and isinstance(node.func, ast.Attribute)
                and node.func.value.id == "weechat"
            ):
                node.func.attr = functions.get(node.func.attr, node.func.attr)
            # count number of tests
            if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "check":
                tests_count += 1

        # replace script variables in string values
        variables = {
            "{SCRIPT_SOURCE}": self.source_script,
            "{SCRIPT_NAME}": self.script_name,
            "{SCRIPT_PATH}": str(self.script_path),
            "{SCRIPT_AUTHOR}": "Sebastien Helleu",
            "{SCRIPT_VERSION}": "1.0",
            "{SCRIPT_LICENSE}": "GPL3",
            "{SCRIPT_DESCRIPTION}": f"{self.language.capitalize()} scripting API test",
            "{SCRIPT_LANGUAGE}": self.language,
            "{SCRIPT_TESTS}": str(tests_count),
        }
        # replace variables
        for node in ast.walk(self.tree):
            if isinstance(node, ast.Constant) and node.value in variables:
                node.value = variables[node.value]

    def write_header(self, output: io.TextIOBase) -> None:
        """Generate script header (just comments by default)."""
        comments = (
            "",
            f"{self.script_name} -- WeeChat {self.language.capitalize()} scripting API testing",
            "",
            "WeeChat script automatically generated by testapigen.py.",
            "DO NOT EDIT BY HAND!",
            "",
            f"Date: {datetime.datetime.now(tz=datetime.timezone.utc)}",
            "",
        )
        output.writelines(f"{self.comment(line)}\n" for line in comments)

    def write(self) -> None:
        """Write script on disk."""
        print(f"Writing script {self.script_path}... ", end="")
        with self.script_path.open("w") as output:
            self.write_header(output)
            self.unparse_class(output).add(self.tree)
            output.write("\n")
            self.write_footer(output)
        print("OK")

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer (nothing by default)."""


class WeechatPythonScript(WeechatScript):
    """A WeeChat script written in Python."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize Python script."""
        super().__init__(UnparsePython, tree, source_script, output_dir, "python", "py")

    def write_header(self, output: io.TextIOBase) -> None:
        """Write header of Python script."""
        super().write_header(output)
        output.write("\nimport weechat")

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer of Python script."""
        super().write_footer(output)
        output.write('\n\nif __name__ == "__main__":\n    weechat_init()\n')


class WeechatPerlScript(WeechatScript):
    """A WeeChat script written in Perl."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize Perl script."""
        super().__init__(UnparsePerl, tree, source_script, output_dir, "perl", "pl")

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer of Perl script."""
        super().write_footer(output)
        output.write("\nweechat_init();\n")


class WeechatRubyScript(WeechatScript):
    """A WeeChat script written in Ruby."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize Ruby script."""
        super().__init__(UnparseRuby, tree, source_script, output_dir, "ruby", "rb")

    def update_tree(self) -> None:
        """Make changes in AST tree of Ruby script."""
        super().update_tree()
        for node in ast.walk(self.tree):
            if isinstance(node, ast.Attribute) and node.value.id == "weechat":
                node.value.id = "Weechat"
            if (
                isinstance(node, ast.Call)
                and isinstance(node.func, ast.Attribute)
                and node.func.attr == "config_new_option"
            ):
                node.args = [*node.args[:11], ast.List(node.args[11:])]


class WeechatLuaScript(WeechatScript):
    """A WeeChat script written in Lua."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize Lua script."""
        super().__init__(UnparseLua, tree, source_script, output_dir, "lua", "lua", comment_char="--")

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer of Lua script."""
        super().write_footer(output)
        output.write("\nweechat_init()\n")


class WeechatTclScript(WeechatScript):
    """A WeeChat script written in Tcl."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize Tcl script."""
        super().__init__(UnparseTcl, tree, source_script, output_dir, "tcl", "tcl")

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer of Tcl script."""
        super().write_footer(output)
        output.write("\nweechat_init\n")


class WeechatGuileScript(WeechatScript):
    """A WeeChat script written in Guile (Scheme)."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize Guile script."""
        super().__init__(UnparseGuile, tree, source_script, output_dir, "guile", "scm", comment_char=";")

    def update_tree(self) -> None:
        """Make changes in AST tree of Guile script."""
        super().update_tree()
        functions_with_list = (
            "config_new_section",
            "config_new_option",
        )
        for node in ast.walk(self.tree):
            if (
                isinstance(node, ast.Call)
                and isinstance(node.func, ast.Attribute)
                and node.func.attr in functions_with_list
            ):
                node.args = [ast.Call("list", node.args)]

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer of Guile script."""
        super().write_footer(output)
        output.write("\n(weechat_init)\n")


class WeechatJavaScriptScript(WeechatScript):
    """A WeeChat script written in JavaScript."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize JavaScript script."""
        super().__init__(UnparseJavaScript, tree, source_script, output_dir, "javascript", "js", comment_char="//")

    def write_footer(self, output: io.TextIOBase) -> None:
        """Writer footer of JavaScript script."""
        super().write_footer(output)
        output.write("\nweechat_init()\n")


class WeechatPhpScript(WeechatScript):
    """A WeeChat script written in PHP."""

    def __init__(self, tree: ast.AST, source_script: str, output_dir: str) -> None:
        """Initialize PHP script."""
        super().__init__(UnparsePhp, tree, source_script, output_dir, "php", "php", comment_char="//")

    def write_header(self, output: io.TextIOBase) -> None:
        """Writer header of PHP script."""
        output.write("<?php\n")
        super().write_header(output)

    def write_footer(self, output: io.TextIOBase) -> None:
        """Write footer of PHP script."""
        super().write_footer(output)
        output.write("\nweechat_init();\n")


# ============================================================================


def update_nodes(tree: ast.AST) -> None:
    """Update the tests AST tree (in-place).

    Actions performed:
      1. add a print message in each test_* function
      2. add arguments in calls to check() function
    """
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
            # add a print at the beginning of each test function
            node.body.insert(0, ast.parse(f'weechat.prnt("", "  > {node.name}");'))
        elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "check":
            # add two arguments in the call to "check" function:
            #   1. the string representation of the test
            #   2. the line number in source (as string)
            # for example if this test is on line 50:
            #   >>> check(weechat.test() == 123)
            # it becomes:
            #   >>> check(weechat.test() == 123, 'weechat.test() == 123', '50')
            output = io.StringIO()
            unparsed = UnparsePython(output=output)
            unparsed.add(node.args[0])
            node.args.append(ast.Constant(output.getvalue()))
            node.args.append(ast.Constant(str(node.func.lineno)))


def get_tests(path: str) -> ast.AST:
    """Parse the source with tests and return the AST node."""
    with Path(path).open() as f:
        test_script = f.read()
        tests = ast.parse(test_script)
        update_nodes(tests)
        return tests


def generate_scripts(source_script: str, output_dir: str) -> Tuple[int, Union[str, None]]:
    """Generate scripts in all languages to test the API."""
    ret_code = 0
    error = None
    try:
        for name, obj in inspect.getmembers(sys.modules[__name__]):
            if (
                inspect.isclass(obj)
                and name != "WeechatScript"
                and name.startswith("Weechat")
                and name.endswith("Script")
            ):
                tests = get_tests(source_script)
                obj(tests, source_script, output_dir).write()
    except Exception as exc:  # noqa: BLE001
        ret_code = 1
        error = f"ERROR: {exc}\n\n{traceback.format_exc()}"
    return ret_code, error


def testapigen_cmd_cb(data: str, buf: str, args: str) -> int:  # noqa: ARG001
    """Callback for WeeChat command /testapigen."""

    def print_error(msg: str) -> None:
        """Print an error message on core buffer."""
        weechat.prnt("", f"{weechat.prefix('error')}{msg}")

    try:
        source_script, output_dir = args.split()
    except ValueError:
        print_error("ERROR: invalid arguments for /testapigen")
        return weechat.WEECHAT_RC_OK
    if not weechat.mkdir_parents(output_dir, 0o755):
        print_error("ERROR: invalid directory: {output_dir}")
        return weechat.WEECHAT_RC_OK
    ret_code, error = generate_scripts(source_script, output_dir)
    if error:
        print_error(error)
    return weechat.WEECHAT_RC_OK if ret_code == 0 else weechat.WEECHAT_RC_ERROR


def get_parser_args() -> argparse.Namespace:
    """Get parser arguments."""
    parser = argparse.ArgumentParser(description=("Generate WeeChat scripts in all languages to test the API."))
    parser.add_argument("script", help="the path to Python script with tests")
    parser.add_argument("-o", "--output-dir", default=".", help="output directory (defaults to current directory)")
    return parser.parse_args()


def main() -> None:
    """Generate all scripts (when script is not loaded in WeeChat)."""
    args = get_parser_args()
    ret_code, error = generate_scripts(args.script, args.output_dir)
    if error:
        print(error)
    sys.exit(ret_code)


if __name__ == "__main__":
    if RUNNING_IN_WEECHAT:
        weechat.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, SCRIPT_DESC, "", "")
        weechat.hook_command(
            SCRIPT_COMMAND,
            "Generate scripting API test scripts",
            "source_script output_dir",
            "source_script: path to source script (testapi.py)\n   output_dir: output directory for scripts",
            "",
            "testapigen_cmd_cb",
            "",
        )
    else:
        main()
