# The Funktionsklempner executable.
#
# Author: Malte J. Ziebarth (malte.ziebarth@tum.de)
#
# Copyright (C) 2025-2026 Technische Universität München
#
# Licensed under the EUPL, Version 1.2 or – as soon they will be approved by
# the European Commission - subsequent versions of the EUPL (the "Licence");
# You may not use this work except in compliance with the Licence.
# You may obtain a copy of the Licence at:
#
# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licence is distributed on an "AS IS" basis,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the Licence for the specific language governing permissions and
# limitations under the Licence.
#
# SPDX-FileCopyrightText: 2025-2026 Technical University of Munich
# SPDX-License-Identifier: EUPL-1.2
# SPDX-FileContributor: Malte J. Ziebarth <malte.ziebarth@tum.de>

from os import mkdir, makedirs
from tomllib import load
from io import TextIOWrapper
from pathlib import Path
from typing import Any, Generator, Iterable, Literal
from itertools import islice, chain
from argparse import ArgumentParser
from tempfile import TemporaryDirectory, TemporaryFile
from importlib import resources
from shutil import copyfileobj

#
# The Funktionsklempner code:
#
from funktionsklempner.generate import (
    generate_python_source, generate_cpp_source,
    generate_cpp_header
)
from funktionsklempner.parse import parse_header
from funktionsklempner.repo import Repository, RelativePath,\
    TOMLLibrary, MesonTarget
from funktionsklempner.wrap.library import WrappedLibrary

#
# Resource files needed later:
#
pre_commit_text = resources.read_text('funktionsklempner.scripts', 'pre-commit')

# Setting up the subproject with 'funktionsklempner.init':
SUBPROJECTFILES = (
    "objektbuch.wrap",
    "gcem.wrap",
    "boost-1.90.0.wrap",
    "packagefiles/gcem/meson.build.0",
    "packagefiles/boost/meson.build.1",
    "packagefiles/boost-1.90.0_units.tar.gz",
    "funktionsklempner/meson.build",
    "funktionsklempner/funktionsklempner/call.hpp",
    "funktionsklempner/funktionsklempner/cdecl.hpp",
    "funktionsklempner/funktionsklempner/default_units.hpp",
    "funktionsklempner/funktionsklempner/fixedmap.hpp",
    "funktionsklempner/funktionsklempner/funktionsklempner.hpp",
    "funktionsklempner/funktionsklempner/math.hpp",
    "funktionsklempner/funktionsklempner/ndarray.hpp",
    "funktionsklempner/funktionsklempner/numpytypes.hpp",
    "funktionsklempner/funktionsklempner/quantityarray.hpp",
    "funktionsklempner/funktionsklempner/scalar.hpp",
    "funktionsklempner/funktionsklempner/strings.hpp",
    "funktionsklempner/funktionsklempner/strings.cpp",
    "funktionsklempner/funktionsklempner/units.hpp",
    "funktionsklempner/funktionsklempner/tartanllama/expected.hpp"
)
def _subproject_resource(p: str) -> tuple[str,bytes]:
    split = p.split("/")
    module = '.'.join(
        chain(
            ('funktionsklempner','subprojectfiles'),
            split[:-1]
        )
    )
    fname = split[-1]

    # Now treat the name of p.
    # This is a hack to revert the rename in
    # the main meson.build file which is necessary
    # to avoid ninja name clashes.
    if fname.startswith('meson.build.'):
        dest_path = "/".join(split[:-1]) + '/meson.build'
    else:
        dest_path = p

    print(f"Copying resource '{p}' to destination '{dest_path}'.")

    return (dest_path, resources.read_binary(module, fname))

SUBPROJECT_RESOURCES: tuple[tuple[str,bytes], ...] = tuple(
    _subproject_resource(p) for p in SUBPROJECTFILES
)

TOPLEVEL_VENDOR_FILES = (
    ("funktionsklempner.templates", "quantityarray.py"),
)
TOPLEVEL_VENDOR_RESOURCES: tuple[tuple[str, str], ...] = tuple(
    (p[1], resources.read_text(*p)) for p in TOPLEVEL_VENDOR_FILES
)




#
# Argument parsing.
# -----------------
# Two commands:
#
#
parser = ArgumentParser()
parser.add_argument('--confirm-all', action='store_true')
parser.add_argument('--strict', action='store_true')
parsers = parser.add_subparsers(required=True, dest='command')
cmd_init = parsers.add_parser('init')
cmd_verify = parsers.add_parser('verify')
cmd_generate = parsers.add_parser('generate')


args = parser.parse_args()


#
# 2. Parsing the TOML configuration
# =================================
#
# This section provides code to parse the TOML configuration of the
# Funktionsklempner software.







_VALID_CONFIG_ENTRIES = set((
    "header", "sources", "link_with", "dependencies",
    "wrapper_source", "include_root"
))


def load_string_list(config: dict[str, Any], element: str) -> list[str]:
    l: list[str] = config[element]
    try:
        assert isinstance(l, list)
        assert all(isinstance(s,str) for s in l)
    except AssertionError:
        raise RuntimeError(
            f"The '{element}' element must be a list of relative paths."
        )
    return l


def load_meson_target_list(
        config: dict[str, Any],
        element: str
    ) -> list[MesonTarget]:
    l = load_string_list(config, element)
    return [
        MesonTarget(s) for s in l
    ]



# Iterate all libraries within the funktionsklempner.toml:
def traverse_libraries(
        config: dict[str, Any],
        toml_path: str,
        repository: Repository
    ) -> Generator[TOMLLibrary]:
    # Check if the current branch of the module tree is a library:
    if "header" in config and "sources" in config:
        # Check validity:
        unknown_keys = set(config.keys()) - _VALID_CONFIG_ENTRIES
        if len(unknown_keys) > 0:
            raise RuntimeError(
                "The following unknown keys have been specified in "
                f"library {toml_path}:\n{", ".join(sorted(unknown_keys))}"
            )
        header_str = config["header"]
        assert isinstance(header_str, str)
        header = RelativePath(repository.root, header_str)
        sources_raw: list[str] = load_string_list(config, "sources")
        sources: list[RelativePath] = [
            RelativePath(root, s) for s in sources_raw
        ]
        link_with: list[MesonTarget] | None = None
        if "link_with" in config:
            link_with = load_meson_target_list(config, "link_with")
        dependencies: list[MesonTarget] | None = None
        if "dependencies" in config:
            dependencies = load_meson_target_list(config, "dependencies")

        # 'wrapper_source': the location where to place the
        # C++ source part of the wrapper. If not given,
        # will place next to the wrapped header (which,
        # depending on the source layout, may pollute the
        # header directory with (a) source file(s)).
        wrapper_source: RelativePath | None = None
        if "wrapper_source" in config:
            wrapper_source_str = config["wrapper_source"]
            assert isinstance(wrapper_source_str, str)
            wrapper_source = RelativePath(root, wrapper_source_str)

            # Sanity checks on the path:
            if wrapper_source.suffix != '.cpp':
                raise RuntimeError(
                    f"wrapper_source '{wrapper_source}' is not a C++ source "
                    "file ending in '.cpp'."
                )
            if not wrapper_source.parent.is_dir():
                raise RuntimeError(
                    f"Parent directory of wrapper_source '{wrapper_source}' "
                    "does not exist."
                )
            if wrapper_source.exists() and not wrapper_source.is_file():
                raise RuntimeError(
                    f"source_root={wrapper_source} exists but is not a file."
                )

        include_root: RelativePath | None = None
        if "include_root" in config:
            include_root_str = config["include_root"]
            assert isinstance(include_root_str, str)
            include_root = RelativePath(root, include_root_str)

            if not include_root.is_dir():
                raise RuntimeError(
                    f"Include directory '{include_root}' does not exist."
                )

        fk_namespace: str | None = config.get("funktionsklempner_namespace")
        if fk_namespace is not None:
            assert isinstance(fk_namespace, str)
            if not fk_namespace[0].isalpha() or not fk_namespace[0].isascii():
                raise RuntimeError(
                    f"Funktionsklempner namespace in library {toml_path} "
                    "needs to start with an ASCII letter."
                )
            check = fk_namespace.replace("::",":")
            if ":" in check:
                raise RuntimeError(
                    "Namespaces need to be separated with two consecutive "
                    "colons '::'."
                )
            check = check.replace("_","")
            if len(check) == 0:
                raise RuntimeError(
                    f"Funktionsklempner namespace in library {toml_path} "
                    "does not have any alpha-numerical characters (which it "
                    "requires)"
                )
            if not check.isalnum() or not check.isascii():
                raise RuntimeError(
                    f"Funktionsklempner namespace in library {toml_path} "
                    "has to consist of ASCII alpha-numeric characters, "
                    "underscores, and colons."
                )


        yield TOMLLibrary(
            repository,
            tuple(toml_path.split('.')),
            header,
            sources,
            link_with,
            dependencies,
            wrapper_source,
            include_root,
            fk_namespace
        )

    # root: Path,
    # python_package_root: RelativePath,
    # module: tuple[str, ...],
    # header: RelativePath,
    # sources: list[RelativePath],
    # link_with: list[MesonTarget] | None,
    # dependencies: list[MesonTarget] | None,
    # wrapper_source: RelativePath | None,
    # include_root: RelativePath | None

    elif len(config) == 0:
        raise RuntimeError(
            "The 'funktionsklempner.toml' file is ill-formatted. Found an "
            "empty branch."
        )

    else:
        # Otherwise descend further:
        for key, val in config.items():
            yield from traverse_libraries(
                val, f"{toml_path}.{key}", repository
            )


#
# 3. Generate the code for a library
# ==================================
#
# This section generates the paths for the glue code files, and
# calls the Funktionsklempner Python module for generating the
# glue code.
#
class GlueCodePaths:
    header: RelativePath
    module: RelativePath
    wrapper_header: RelativePath
    wrapper_source: RelativePath
    include_root: RelativePath

    def __init__(self,
            header_path: RelativePath,
            module_path: RelativePath,
            wrapper_header_path: RelativePath,
            wrapper_source_path: RelativePath,
            include_root: RelativePath
        ):
        self.header = header_path
        self.module = module_path
        self.wrapper_header = wrapper_header_path
        self.wrapper_source = wrapper_source_path
        self.include_root = include_root


def glue_code_paths(
        lib: TOMLLibrary,
        repository: Repository,
        cpp_header_out_root: RelativePath | None = None,
        cpp_source_out_root: RelativePath | None = None
    ) -> GlueCodePaths:
    header_path = lib.header_path()
    if cpp_header_out_root is None:
        # Default header output path: next to the wrapped header.
        cpp_header_out_root = header_path.parent

    if cpp_source_out_root is None:
        # Default source output path:
        # 1) next to the collected sources (if those are in the same directory)
        # 2) next to the Python module
        cpp_source_out_root = repository.python_package_root
        if len(lib.sources) > 0:
            csor = lib.sources[0].parent
            all_same_path = all(
                s.parent == csor
                for s in islice(lib.sources, 1, None)
            )
            if all_same_path:
                cpp_source_out_root = csor

    module_path = lib.module_path()
    wrapper_header_path = lib.wrapper_header_path()
    wrapper_source_path = lib.wrapper_source_path()


    return GlueCodePaths(
        header_path,
        module_path,
        wrapper_header_path,
        wrapper_source_path,
        lib.include_root_path()
    )


def generate_glue_code(
        lib: TOMLLibrary,
        repository: Repository,
        #src_root: Path,
        #py_out_root: Path,
        #package: str,
        strict: bool,
        cpp_header_out_root: RelativePath | None = None,
        cpp_source_out_root: RelativePath | None = None
    ) -> GlueCodePaths:
    """
    Generates glue code for a library.
    """
    paths = glue_code_paths(
        lib,
        repository,
        cpp_header_out_root=cpp_header_out_root,
        cpp_source_out_root=cpp_source_out_root
    )

    fk_lib = parse_header(
        paths.header,
        lib.module,
        repository,
        lib.license_boilerplate,
        lib.accept_any_email,
        lib.extended_author_syntax,
        lib.fk_namespace,
        strict,
        paths.include_root,
        False
    )

    wrapped = WrappedLibrary(fk_lib)

    # Write the source files
    with open(paths.module.to_path(), 'w', encoding='utf-8') as f:
        f.writelines(generate_python_source(wrapped))
    with open(paths.wrapper_header.to_path(), 'w', encoding='utf-8') as f:
        f.writelines(generate_cpp_header(wrapped))
    with open(paths.wrapper_source.to_path(), 'w', encoding='utf-8') as f:
        f.writelines(generate_cpp_source(wrapped))


    # Return the generated files:
    return paths


CppStd = Literal['c++20']


def generate_meson_build_patch(
        libs: Iterable[TOMLLibrary],
        repository: Repository
    ) -> Generator[str]:
    """
    Generate the meson.build file for the 'funktionsklempner' subproject.
    """
    qa_path = repository.python_package_root / '_funktionsklempner' \
        / 'quantityarray.py'
    yield (
        "#\n"
        "# The code between 'FUNKTIONSKLEMPNER BEGIN' and 'FUNKTIONSKLEMPNER END'\n"
        "# is auto-generated from the funktionsklempner.toml file.\n"
        "#\n"
        "funktionsklempner = subproject('funktionsklempner')\n"
        "funktionsklempner_dep = funktionsklempner.get_variable('funktionsklempner_dep')\n"
        "fk_py = import('python').find_installation(pure: true)\n"
        "fk_py_install_dir = fk_py.get_install_dir()\n"
        "fk_py.install_sources(\n"
       f"    '{qa_path.as_posix(repository.root)}',\n"
       f"    subdir: '{repository.package_name}/_funktionsklempner'\n"
        ")\n\n"
    )

    for lib in libs:
        # Obtain the glue code paths:
        paths = glue_code_paths(lib, repository)

        # Include directories:
        include: list[RelativePath]
        if lib.include_root:
            include = [lib.include_root]
        else:
            include = [paths.header.parent]

        fname = lib.module[-1]
        plen = len(lib.module)
        name = "_".join(lib.module)
        #install_dir =
        # Construct the install path from the module:
        directory_path_obj = " / ".join(f"'{s}'" for s in islice(lib.module, None, plen-1))
        directory_path_str = "/".join(islice(lib.module, None, plen-1))

        yield (
           f"{name} = shared_library(\n"
           f"    '{fname}',\n"
        )
        yield from (
           f"    '{s.as_posix(repository.root)}',\n" for s in lib.sources
        )
        #
        # The shared library also depends on the wrapper source,
        # which will be written to 'paths.wrapper_source':
        #
        yield (
           f"    '{paths.wrapper_source.as_posix(repository.root)}',\n"
            "    include_directories: include_directories([\n"
        )
        yield from (
           f"        '{ip.as_posix(repository.root)}',\n" for ip in include
        )

        yield (
            "    ]),\n"
            "    dependencies: [\n"
            "        funktionsklempner_dep,\n"
        )
        if lib.dependencies is not None:
            yield from (
               f"        {dep},\n"
                for dep in lib.dependencies
            )
        yield (
            "    ],\n"
        )
        if lib.link_with is not None:
            yield "    link_with: [\n"
            yield from (
                f"        {link},\n"
                for link in lib.link_with
            )
            yield "    ],\n"

        yield (
            "    name_prefix: 'lib',\n"
           f"    install_dir: fk_py_install_dir / {directory_path_obj},\n"
            "    install: true\n"
            ")\n"
            "fk_py.install_sources(\n"
           f"    '{paths.module.as_posix(repository.root)}',\n"
           f"    subdir: '{directory_path_str}'\n"
            ")\n"
        )


KEYWORD_FUNKTIONSKLEMPNER_BEGIN = "# FUNKTIONSKLEMPNER BEGIN"
KEYWORD_FUNKTIONSKLEMPNER_END = "# FUNKTIONSKLEMPNER END"

def patch_meson_build(
        file: Path,
        libs: Iterable[TOMLLibrary],
        repository: Repository
    ):
    """
    Patches the main meson.build file to include the build code
    auto-generated from `libs`.
    """
    if not file.is_file():
        if not file.exists():
            raise RuntimeError(
               f"The to-be-patched Meson build file '{file.resolve()}' does "
                "not exist."
            )
        else:
            raise RuntimeError(
               f"The to-be-patched Meson build file '{file.resolve()}' is not a "
                "file."
            )

    # If the file exists,
    #   1) check whether the Funktionsklempner patch has already been inserted and
    #   2) insert the patch.
    with open(file, 'r', encoding='utf-8') as f:
        lines = f.readlines()

    fk_begin = 0
    for line in lines:
        if line.startswith(KEYWORD_FUNKTIONSKLEMPNER_BEGIN):
            break
        fk_begin += 1

    fk_end: int | None = None
    if fk_begin < len(lines):
        fk_end = fk_begin
        for line in islice(lines, fk_begin, len(lines)):
            if line.startswith(KEYWORD_FUNKTIONSKLEMPNER_END):
                break
            fk_end += 1

    with TemporaryFile() as tf:
        # Now generate and insert the patch:
        patch_generator = generate_meson_build_patch(
            libs,
            repository
        )
        if fk_end is None:
            leading_newline = "\n"
        else:
            leading_newline = ""

        # Opened the temporary file in binary mode to use copyfileobj
        # later. Wrap it into a text io wrapper to write strings to it:
        tf_io = TextIOWrapper(tf, write_through=True)
        tf_io.writelines(islice(lines, 0, fk_begin))
        tf_io.write(f"{leading_newline}{KEYWORD_FUNKTIONSKLEMPNER_BEGIN}\n")
        tf_io.writelines(patch_generator)
        tf_io.write(f"{KEYWORD_FUNKTIONSKLEMPNER_END}\n")
        if fk_end is not None:
            tf_io.writelines(islice(lines, fk_end+1, len(lines)))

        tf_io.detach()

        # Copy the temporary file to overwrite the current Meson file:
        tf.seek(0)
        with open(file, 'wb') as f:
            copyfileobj(tf, f)





# Content-based file comparison for verification:
def file_compare(p0: Path, p1: Path) -> bool:
    if p0.stat().st_size != p1.stat().st_size:
        return False
    with open(p0, 'rb') as f0, open(p1, 'rb') as f1:
        return f0.read() == f1.read()



#
# 4. Load the configuration
# =========================
#
# This part of the script will be needed in all Funktionsklempner commands.
# It parses the `funktionsklempner.toml` file, thus ensuring that a valid
# Funktionsklempner definition exists (which is a prerequisite even for
# initializing only the git hook!).

root = Path.cwd()
conffile = root / 'funktionsklempner.toml'
if not conffile.is_file():
    raise RuntimeError(
        "Funktionsklempner configuration file 'funktionsklempner.toml' "
        "not found in directory '" + str(root) + "'."
    )

# Load the configuration:
with open(conffile, 'rb') as f:
    config = load(f)

# Get and validate the package configuration:
package_config = config['package']
if sorted(package_config.keys()) != ['name','root']:
    raise RuntimeError(
        "The entries under the root node 'package' have to be 'name' "
        "and 'root'."
    )
repository = Repository(
    root,
    RelativePath(root, package_config['root']),
    package_config['name']
)


#
# 5. Execute the sub commands
# ===========================
#
def user_confirm(question: str) -> bool:
    """
    Ask the user to confirm a question.
    """
    # --confirm-all argument:
    if args.confirm_all:
        return True
    valid: bool = False
    while not valid:
        answer = input(question + " [y/N]").strip().lower()
        if len(answer) == 0:
            return False
        elif answer == "y":
            return True
        elif answer == "n":
            return False


def handle_precommit_hook(precommit: Path):
    """
    Handles an existing pre-commit hook.
    """
    if precommit.exists():
        # Check whether the precommit hook contains the Funktionsklempner hook:
        with open(precommit, 'r', encoding='utf-8') as f:
            while len(line := f.readline()) > 0:
                if line.startswith("funktionsklempner verify"):
                    print(
                        "Pre-commit hook already initialized."
                    )
                    return

        # Need to append to the pre-commit hook. Confirm this with the
        # user
        choice = user_confirm(
            f"Pre-commit hook '{precommit.resolve()}' exists. "
            "Append the Funktionsklempner hook to this file?"
        )
        if not choice:
            print("Aborting.")
            return
        # Append the pre-commit hook:
        with open(precommit, 'a', encoding='utf-8') as f:
            f.write(pre_commit_text)
        print(f"Successfully appended to '{precommit.resolve()}'.")
    else:
        with open(precommit, 'w', encoding='utf-8') as f:
            f.write(pre_commit_text)
        print(f"Successfully wrote pre-commit hook '{precommit.resolve()}'.")


def run():
    meson_build = root / 'meson.build'
    if args.command == 'init':
        # Init the current directory.
        # This creates the git pre-commit hook ensuring that the
        # generated code is updated before committing.
        gitdir = root / '.git'
        if not gitdir.is_dir():
            raise RuntimeError(
                "Current directory needs to be a git repository."
            )
        if not (gitdir / 'hooks').is_dir():
            mkdir(gitdir / 'hooks')

        # Now check whether the hook exists already:
        precommit = gitdir / 'hooks' / 'pre-commit'
        handle_precommit_hook(precommit)

        # Insert the "subprojet('funktionsklempner')" snippet into the project's
        # meson.build file::
        patch_meson_build(meson_build, [], repository)

        # Setup the Funktionsklempner subproject for compiling the error
        # handling code:
        subprojectdir = root / 'subprojects'
        def ensure_directory(dir: Path):
            if not dir.exists():
                makedirs(dir)
            elif not dir.is_dir():
                raise RuntimeError(
                    f"'{dir.resolve()}' has to be a directory."
                )
        ensure_directory(subprojectdir)
        for file, content in SUBPROJECT_RESOURCES:
            components = file.split('/')
            # Get the full path to the directory the file resides in
            # and ensure that this directory exists:
            if len(components) > 1:
                directory = subprojectdir.joinpath(
                    *islice(components, len(components)-1)
                )
            else:
                directory = subprojectdir
            ensure_directory(directory)
            with open(directory / components[-1], 'wb') as f:
                f.write(content)

        # Setup the '._funktionsklempner' top-level submodule
        fk_toplevel = repository.python_package_root.full() / "_funktionsklempner"
        ensure_directory(Path(fk_toplevel))
        for fname, content in TOPLEVEL_VENDOR_RESOURCES:
            with open(fk_toplevel / fname, 'w', encoding='utf-8') as f:
                f.write(content)



    elif args.command == 'generate':
        # Generate the source code.
        libraries = list(
            traverse_libraries(
                config[repository.package_name],
                repository.package_name,
                repository
            )
        )
        for lib in libraries:
            paths = generate_glue_code(
                lib,
                repository,
                args.strict
            )

        # Write the meson.build file:
        patch_meson_build(meson_build, libraries, repository)


    elif args.command == 'verify':
        # Verify the current directory.

        # Get all of the libraries:
        libraries = list(
            traverse_libraries(
                config[repository.package_name],
                repository.package_name,
                repository
            )
        )

        # Now verify all of the libaries:
        with TemporaryDirectory() as tmpdir:
            tmproot = Path(tmpdir)
            tmp_cpp_path = RelativePath(tmproot, "cpp")
            tmp_repo = Repository(
                tmproot,
                RelativePath(tmproot, "python"),
                repository.package_name
            )
            for lib in libraries:
                # Generate the glue code:
                tmp_paths = generate_glue_code(
                    lib,
                    tmp_repo,
                    args.strict,
                    cpp_header_out_root=tmp_cpp_path,
                    cpp_source_out_root=tmp_cpp_path
                )

                # The current package code:
                paths = glue_code_paths(lib, repository)

                # Validate the glue code:
                if not file_compare(
                        tmp_paths.module.to_path(), paths.module.to_path()
                    ):
                    raise RuntimeError(
                        "Different in generated Python code for module '"
                        f"{".".join(lib.module)}'."
                    )
                if not file_compare(
                        tmp_paths.wrapper_header.to_path(),
                        paths.wrapper_header.to_path()
                    ):
                    raise RuntimeError(
                        "Different in generated wrapper header code for module '"
                        f"{".".join(lib.module)}'."
                    )
                if not file_compare(
                        tmp_paths.wrapper_source.to_path(),
                        paths.wrapper_source.to_path()
                    ):
                    raise RuntimeError(
                        "Different in generated C++ source code for module '"
                        f"{".".join(lib.module)}'."
                    )
