#!/usr/bin/env python


# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# Copyright (c) 2024-2026, Maciej Barć <xgqt@xgqt.org>


"""
XMach, the cross-platform task runner.
"""


import argparse
import glob
import inspect
import json
import subprocess
import sys

from shutil import copy
from typing import Self
from os import chdir, environ, path

from typing import Any


__app__ = "XMach"
__version__ = "1.0.0"
__description__ = "Cross-platform task runner"


class ConstantGlobals:
    """
    Constants.

    Class containing constant global variable definitions.
    """

    SOURCE_ROOT: str = environ.get("SOURCE_ROOT") or path.abspath(path=path.curdir)
    XMACH_CONFD: str = environ.get("XMACH_CONFD") or path.join(SOURCE_ROOT, "xmach.d")

    XMACH_EXAMPLE_CONFIG_FILE: str = path.join(XMACH_CONFD, "xmach-config.example.json")
    XMACH_CONFIG_FILE: str = path.join(XMACH_CONFD, "xmach-config.json")


class MutableGlobals:
    """
    Mutable globals.

    Class containing mutable global variable definitions.
    """

    # Mutable
    JOBS: int = 1


class ActionScriptsBase:
    """
    Action scripts base class.

    Collection of base action functions.
    """

    def action__list(self, _: list[str]) -> None:
        """
        Action: list
        """

        print(" Those actions are available:")

        for function_name, _function_object in self._get_all_methods():
            if "action__" in function_name:
                pretty_name: str = function_name
                pretty_name = pretty_name.replace("action__", "")
                pretty_name = pretty_name.replace("_", "-")

                print(f"   - {pretty_name}")

    def action__write_default_config(self, _: list[str]) -> None:
        """
        Action: write-default-config
        """

        if not path.exists(path=ConstantGlobals.XMACH_EXAMPLE_CONFIG_FILE):
            err: str = f"File {ConstantGlobals.XMACH_EXAMPLE_CONFIG_FILE} does not exists"

            raise FileNotFoundError(err)

        # Backup the old config if it exists.
        if path.exists(ConstantGlobals.XMACH_CONFIG_FILE):
            backup_config: str = f"{ConstantGlobals.XMACH_CONFIG_FILE}.bak"

            print(f"Copying {ConstantGlobals.XMACH_CONFIG_FILE} into {backup_config}")

            copy(
                src=ConstantGlobals.XMACH_CONFIG_FILE,
                dst=backup_config,
                follow_symlinks=True,
            )

        print(
            f"Copying {ConstantGlobals.XMACH_EXAMPLE_CONFIG_FILE} "
            + f"into {ConstantGlobals.XMACH_CONFIG_FILE}"
        )

        copy(
            src=ConstantGlobals.XMACH_EXAMPLE_CONFIG_FILE,
            dst=ConstantGlobals.XMACH_CONFIG_FILE,
            follow_symlinks=True,
        )

    def get_action(self, action_name: str):
        """
        Returns method associated with given action_name.
        """

        canonical_action_name: str | None = self._resolve_noncanonical_method_name(
            name="action__" + action_name
        )

        if canonical_action_name:
            return getattr(self, canonical_action_name)

        raise RuntimeError(f"Action was not found, given: {action_name}")

    def _get_all_methods(self):
        return inspect.getmembers(object=ActionScripts, predicate=inspect.isfunction)

    def _resolve_noncanonical_method_name(self, name: str) -> str | None:
        canonical_method_name: str = name.replace("-", "_")

        if hasattr(self, canonical_method_name):
            return canonical_method_name

        return None


class AppRunner:
    """
    App runner.

    Runner that defines how the main entrypoint is executed.
    """

    def __init__(self) -> None:
        cli = CliBuilder(
            app=__app__,
            version=__version__,
            description=__description__,
        )

        self.load_confd(ConstantGlobals.XMACH_CONFD)

        self._args = cli.add_arguments().parse().get_args()

        if ActionScripts:
            self._action_scripts = ActionScripts()
        else:
            self._action_scripts = None

    def run(self) -> None:
        MutableGlobals.JOBS = self._args.jobs

        chdir(path=ConstantGlobals.SOURCE_ROOT)

        res: int = 0
        action_names: list[str] = self._args.actions.split(",")

        for action_name in action_names:
            method = self._action_scripts.get_action(action_name=action_name)
            res = method(self._args.action_args)

        sys.exit(res)

    def load_confd(self, confd_directory):
        if not path.isdir(confd_directory):
            return

        confd_pattern = path.join(confd_directory, "xmach_*.py")

        for confd_file in glob.glob(confd_pattern):
            with open(file=confd_file, mode="r") as f:
                code = f.read()

            exec(compile(code, confd_file, "exec"), globals())


class XmachConfig:
    """
    Xmach config.

    Xmach config loader and retriever.
    """

    def __init__(self) -> None:
        self.__data: dict[str, dict[str, str]] = {}

    def load(self) -> Self:
        if not path.exists(path=ConstantGlobals.XMACH_CONFIG_FILE):
            return self

        with open(file=ConstantGlobals.XMACH_CONFIG_FILE, encoding="utf-8") as f:
            self.__data = json.load(fp=f)

        return self

    def get_environment(self) -> dict[str, str]:
        return self.__data.get("environment", {})

    def get_scons_args(self) -> list[str]:
        scons_args: list[str] = []

        for key, value in self.__data.get("scons_args", {}).items():
            if value:
                scons_args.append(f"{key}={value}")

        return scons_args


class CliBuilder:
    """
    CLI builder.

    Defines the CLI interface and allowed arguments.
    """

    def __init__(self, app: str, version: str, description: str, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

        self._parser: argparse.ArgumentParser = argparse.ArgumentParser(
            prog=app,
            description=f"{app}, version {version}. {description}.",
        )
        self.__args: argparse.Namespace | None = None

        self._parser.add_argument(
            "-V",
            "--version",
            action="version",
            version=f"{version}",
        )

    def parse(self) -> Self:
        self.__args = self._parser.parse_args()

        return self

    def get_args(self) -> argparse.Namespace:
        if not self.__args:
            raise RuntimeError("Arguments are not yet parsed")

        return self.__args

    def add_arguments(self) -> Self:
        self._parser.add_argument(
            "-j",
            "--jobs",
            help="number of jobs to run at the same time",
            type=int,
            default=MutableGlobals.JOBS,
        )
        self._parser.add_argument(
            "actions",
            help="actions to execute (comma separated)",
            type=str,
        )
        self._parser.add_argument(
            "action_args",
            help="additional action arguments",
            nargs="*",
        )

        return self


class RunCmd:
    """
    Run cmd.
    """

    @staticmethod
    def run_cmd(cmd: list[str], **kwargs) -> None:
        config_env: dict[str, str] = XmachConfig().load().get_environment()

        subprocess_env: dict[str, str] = environ.copy()
        subprocess_env.update(config_env)

        subprocess.run(
            args=cmd,
            env=subprocess_env,
            capture_output=False,
            check=True,
            **kwargs,
        )


class Program:
    """
    Main program.

    Boilerplate that executed AppRunner.
    """

    @staticmethod
    def main() -> None:
        app_runner = AppRunner()

        app_runner.run()


# Placeholder, this should be defined by the user.
ActionScripts: Any = None


if __name__ == "__main__":
    Program.main()
