"""
Runtime helpers to make registration and regeneration of OGN Python files automatic
"""
import os
import re
from importlib import import_module
from pathlib import Path
from typing import Callable, Dict, List, Tuple

import carb
import omni.graph.tools as ogt
import omni.graph.tools.ogn as ogn

from .settings import Settings
from .versions import (
    Compatibility,
    check_version_compatibility,
    get_generator_extension_version,
    get_target_extension_version,
)


# ================================================================================
def find_import_locations(path_requesting_registration: str, import_path: str) -> str:
    """Returns the root directory of the import path

    Args:
        path_requesting_registration: File/directory that called this function, assumed to be in the generated directory
        import_path: Python module import path for the extension

    Returns:
        (import_root, generated_directory)
            import_root: Directory at which the import_path is rooted
            generated_directory: Directory containing the generated files

    Raises:
        ValueError: If the import_path was not found in the generated directory path

    Example:
        find_import_locations("C:/a/b/c/d.py", import_path="b.c")
            ("C:/a", "C:/a/b/c")
    """
    if os.path.isdir(path_requesting_registration):
        generated_directory = os.path.realpath(path_requesting_registration)
    else:
        generated_directory = os.path.dirname(os.path.realpath(path_requesting_registration))
    canonical_directory = generated_directory.replace("\\", "/")
    import_directory = import_path.replace(".", "/")
    found_import = re.match(f"(.*)/{import_directory}/.*", canonical_directory)
    if found_import is None:
        found_import = re.match(f"(.*)/{import_directory}$", canonical_directory)
    if found_import:
        return (found_import.group(1), canonical_directory)

    raise ValueError(f"Import path {import_directory} not found in generated directory {canonical_directory}")


# ================================================================================
def regenerate_files(
    ogn_file_path: str, database_file_path: str, import_path: str
) -> Tuple[bool, bool, bool, bool, bool]:
    """Run the generation code to generate a new Python database file and test file from a (modified) .ogn file

    Args:
        ogn_file_path: Path to the .ogn file to use for regeneration
        database_file_path: Path to the XXDatabase.py file being generated
        import_path: Python import path of the file requesting the registration (e.g. omni.graph.core)

    Returns:
        (generated_python, generated_test, generated_cpp, generated_documentation, generated_usd):
            generated_python: Did a Python interface file get generated?
            generated_tests: Did a Test file get generated?
            generated_cpp: Did a node header file get generated?
            generated_documentation: Did a docs file get generated?
            generated_usd: Did a USD template file get generated?
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Generating database on {} from {}", database_file_path, ogn_file_path)
    generated_python = False
    generated_tests = False
    generated_cpp = False
    generated_documentation = False
    generated_usd = False
    try:
        # If the Kit location is available then use it to find configuration files
        try:
            config_dir = Path(carb.tokens.get_tokens_interface().resolve("${kit}")).parent.parent.parent.joinpath(
                "_build", "ogn", "config"
            )
        except AttributeError:
            config_dir = None
        # Parse the .ogn file
        with open(ogn_file_path, "r", encoding="utf-8") as ogn_fd:
            node_interface_wrapper = ogn.NodeInterfaceWrapper(
                ogn_fd, extension=import_path, config_directory=str(config_dir)
            )
        _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      Generated a wrapper {}", node_interface_wrapper)
        try:
            all_supported = True
            node_interface_wrapper.check_support()
        except ogn.UnimplementedError:
            all_supported = False

        # Set up the configuration to write into the correct directories
        base_name, _ = os.path.splitext(os.path.basename(ogn_file_path))
        configuration = ogn.GeneratorConfiguration(
            ogn_file_path,
            node_interface_wrapper.node_interface,
            import_path,
            import_path,
            base_name,
            os.path.dirname(database_file_path),
            ogt.OGN_REG_DEBUG,
            Settings.generator_settings(),
        )
        _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("Generation configuration\n{}", configuration)

        # Generate the Python NODEDatabase.py file if this .ogn description allows it
        if node_interface_wrapper.can_generate("python"):
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                "      Generating the Python in {}", configuration.destination_directory
            )
            ogn.generate_python(configuration)
            generated_python = True
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      ...Skipping -> File cannot generate Python database")

        # Generate the Python TestNODE.py file if this .ogn description allows it
        if node_interface_wrapper.can_generate("tests"):
            configuration.destination_directory = os.path.join(os.path.dirname(database_file_path), "tests")
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                "      Generating the tests in {}", configuration.destination_directory
            )
            ogn.generate_tests(configuration)
            generated_tests = True
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      ...Skipping -> File cannot generate tests")

        # Generate the C++ interface file NODEDatabase.h if this .ogn description allows it
        if node_interface_wrapper.can_generate("c++"):
            configuration.destination_directory = os.path.join(os.path.dirname(database_file_path), "include")
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                "      Generating the C++ header file in {}", configuration.destination_directory
            )
            ogn.generate_cpp(configuration, all_supported)
            generated_cpp = True
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      ...Skipping -> File cannot generate C++ interface file")

        # Generate the documentation file NODE.rst if this .ogn description allows it
        if node_interface_wrapper.can_generate("docs"):
            configuration.destination_directory = os.path.join(os.path.dirname(database_file_path), "docs")
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                "      Generating the documentation file in {}", configuration.destination_directory
            )
            ogn.generate_documentation(configuration)
            generated_documentation = True
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      ...Skipping -> File cannot generate documentation file")

        # Generate the USD template file NODE.usda if this .ogn description allows it
        if node_interface_wrapper.can_generate("usd"):
            configuration.destination_directory = os.path.join(os.path.dirname(database_file_path), "tests", "usd")
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                "      Generating the USD template file in {}", configuration.destination_directory
            )
            ogn.generate_usd(configuration)
            generated_usd = True
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      ...Skipping -> File cannot generate USD template file")

    except ogn.ParseError as error:
        carb.log_error(f"Node parsing of {ogn_file_path} failed with error {error}")

    except ogn.NodeGenerationError as error:
        carb.log_error(f"Node generation on {ogn_file_path} failed with error {error}")

    return (generated_python, generated_tests, generated_cpp, generated_documentation, generated_usd)


# ================================================================================
def find_ogn_and_python_interfaces(import_directory: str) -> Tuple[Dict[str, str], List[str]]:
    """Scan the directory for generated database files and directories containing .ogn files

    Args:
        import_directory: Root directory to scan

    Returns:
        (database_files, ogn_directories):
            database_files: Dictionary of file:path to Python Database files generated from .ogn files
            ogn_directories: List of directories that may contain .ogn files to use for generation
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Scanning the import directory for .ogn and generated Python files")
    database_files = {}
    ogn_directories = []
    # If the "nodes" directory was found in the ogn/ tree then it won't have to be scanned from its
    # normal location one level up. (The latter only being necessary when Windows permissions causes
    # failure of creation of the symlink to it.)
    found_nodes_link = False
    for ogn_file in os.listdir(import_directory):
        ogn_path = os.path.join(import_directory, ogn_file)
        if os.path.isfile(ogn_path):
            if ogn_file.endswith("Database.py"):
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Found database file {}", ogn_path)
                database_files[ogn_file] = ogn_path
            elif ogn_file == "tests":
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Found generated tests/ subdirectory")
            else:
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Found uninteresting file {}", ogn_path)
        elif os.path.isdir(ogn_path):
            if ogn_file == "tests":
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Found generated tests/ subdirectory")
            elif ogn_file == "__pycache__":
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Skipping __pycache__ subdirectory")
            else:
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Adding potential OGN directory {}", ogn_path)
                if ogn_file == "nodes":
                    found_nodes_link = True
                ogn_directories.append(ogn_path)
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Skipping as neither file nor directory {}", ogn_path)

    # If the user is not able to create symlinks then the nodes would live above the import directory
    # in a directory named "nodes" (possibly with other directories below it)
    if not found_nodes_link:
        unlinked_nodes_directory = os.path.normpath(os.path.join(import_directory, "..", "nodes"))
        if os.path.isdir(unlinked_nodes_directory):
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                "    Adding scan of unlinked node directory {}", unlinked_nodes_directory
            )
            ogn_directories.append(unlinked_nodes_directory)

    return (database_files, ogn_directories)


# ================================================================================
def scan_directories_for_ogn_files(
    ogn_directories: List[str], import_root: str
) -> Tuple[Dict[str, str], List[Tuple[str, str]]]:
    """Scan a list of directories to find all .ogn files below them

    Args:
        ogn_directories: List of directory paths to scan
        import_root: Path to the root directory for Python imports (nodes will import relative to this path)

    Returns:
        (ogn_files, node_files):
            ogn_files: {file_name: path_name} for all .ogn files below any of the given directories
            node_files: List of (node_name, import_to_node) to Python node implementation files
                node_name: Name of the node (base file name with the extension stripped)
                import_to_node: Python import path relative to the import to this directory
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Scanning for .ogn files in {}", import_root)
    ogn_files = {}
    node_files = {}
    for ogn_directory in ogn_directories:  # noqa: PLR1702
        _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Checking OGN directory {}", ogn_directory)
        for root, _, files in os.walk(ogn_directory, followlinks=True):
            for file in files:
                if file.endswith(".ogn"):
                    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("      Found OGN file {}/{}", root, file)
                    ogn_files[file] = os.path.join(root, file)
                    # Check to see if there is also an implementation file in this same directory, the only
                    # configuration that is automatically recognized.
                    implementation_file = file.replace(".ogn", ".py")
                    if os.path.isfile(os.path.join(root, implementation_file)):
                        try:
                            import_to_node = root.replace("\\", ".").replace("/", ".")[len(import_root) + 1 :]
                            node_name = f"{os.path.splitext(implementation_file)[0]}"
                            node_files[node_name] = import_to_node
                            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                                "      Added file {} at {}", node_name, import_to_node
                            )
                        except KeyError as error:
                            carb.log_error(
                                f"Could not find Python import root from {root}/{implementation_file} - {error}"
                            )

    return (ogn_files, node_files)


# ================================================================================
def scan_for_generated_tests(test_directory: str) -> List[str]:
    """Scan a directory for files that were automatically generated as tests for .ogn files

    Args:
        test_directory: Root path of the directory containing tests

    Returns:
        List of test file root names, for use in import statements (test classes have the same name as the file)
    """
    test_node_names = []
    for file in os.listdir(test_directory):
        if file.startswith("Test") and file.endswith(".py"):
            node_name = f"{os.path.splitext(file)[0]}"
            test_node_names.append(node_name)
    return test_node_names


# ================================================================================
def files_needing_regeneration(
    ogn_files: Dict[str, str], database_files: Dict[str, str], import_directory: str
) -> List[Tuple[str, str]]:
    """Look for Python Database files that are out of date compared to their corresponding .ogn file

    Args:
        ogn_files: FILE:PATH for all .ogn files found in the current directory
        database_files: FILE:PATH for all generated Python Database files found in the current directory
        import_directory: Root path of this extensions's Python import

    Returns:
        List of (ogn_path, database_path, exists) with all files that need regenerating
            ogn_path: Full path to the .ogn file to use for generation
            database_path: Destination path of the generated Python Database file
            exists: The destination file exists, it is just out of date
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Checking if {} .ogn files need regenerating", len(ogn_files))
    to_be_generated = []
    for ogn_file, ogn_path in ogn_files.items():
        database_file = ogn_file.replace(".ogn", "Database.py")
        database_path = os.path.join(import_directory, database_file)
        regenerate_node = False
        file_exists = database_file in database_files
        if file_exists:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    Checking status of {} against {}", database_file, ogn_file)
            database_modified_time = os.stat(database_files[database_file]).st_mtime
            ogn_modified_time = os.stat(ogn_path).st_mtime
            if database_modified_time >= ogn_modified_time:
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    -> {} Up to date", ogn_file)
            else:
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    -> {} Out of date, regenerating", ogn_file)
                regenerate_node = True
        else:
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("    {} No database file, regenerating", ogn_file)
            regenerate_node = True
        if regenerate_node:
            to_be_generated.append((ogn_path, database_path, file_exists))

    return to_be_generated


# ================================================================================
def register_existing_python_nodes(
    python_nodes: Dict[str, str], import_path: str, generated_directory: str
) -> List[Callable]:
    """Register all of the existing Python nodes with OmniGraph

    Args:
        python_nodes: Dictionary of node_name:import_root for all Python node files found
            node_name: Name of the node, which is the file name with the extension stripped
            import_root: Import path of the node within this extension
        import_path: Absolute Python import path to the directory above the generated ogn/ directory
        generated_directory: Location of the files generated from the .ogn files in this extension

    Returns:
        List of methods that will deregister all of the nodes that were registered
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
        "Registering python nodes from {} to generated directory {}", import_path, generated_directory
    )
    deregistration_methods = []
    for (node_name, import_root) in python_nodes.items():
        _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Registering python node {} from {}", node_name, import_root)
        try:
            try:
                node_module = import_module(f".{node_name}", import_root)
            except Exception as error:
                raise ogn.CarbLogError(f"Failed to import Python OmniGraph node {node_name}.py - {error}")

            try:
                node_class = getattr(node_module, node_name)
            except AttributeError as error:
                raise ogn.CarbLogError(
                    f"Failed to import Python OmniGraph node {node_name}.py -"
                    " file does not contain a class of the same name"
                ) from error

            try:
                database_module_name = f"{node_name}Database"
                database_module = import_module(f".ogn.{database_module_name}", import_path)
            except ModuleNotFoundError as error:
                raise ogn.DebugError(
                    f"  Dynamic registration of Python OmniGraph node {node_name} failed, probably no .ogn file"
                ) from error

            try:
                database_class = getattr(database_module, database_module_name)
            except AttributeError as error:
                raise ogn.CarbLogError(
                    f"Python OGN interface node {database_module_name}.py has no node class of the same name"
                ) from error

            try:
                # The defaults are the last version prior to the versions being embedded in the generated code
                generator_version = getattr(database_class, "GENERATOR_VERSION", (1, 1, 1))
                target_version = getattr(database_class, "TARGET_VERSION", (2, 2, 0))
                compatibility = check_version_compatibility(generator_version, target_version)
                if compatibility == Compatibility.FullyCompatible:  # noqa: SIM114 because code will be changing
                    database_class.register(node_class)
                    deregister_method = getattr(database_class, "deregister", None)
                    if deregister_method is not None:
                        deregistration_methods.append(deregister_method)
                elif compatibility == Compatibility.MajorVersionCompatible:
                    # TODO: In order to regenerate we must be able to find the node from which this came.
                    #       As that's currently not possible this will just do normal registration until it is.
                    # carb.log_warn(
                    #  f"Older version of Python node {node_class.__name__} will regenerate the node database\n"
                    #  f"   Generator used {generator_version}, this one is {get_generator_extension_version()}\n"
                    #  f"   Target was {target_version}, this one is {get_target_extension_version()}\n"
                    # )
                    database_class.register(node_class)
                    deregister_method = getattr(database_class, "deregister", None)
                    if deregister_method is not None:
                        deregistration_methods.append(deregister_method)
                elif compatibility == Compatibility.Incompatible:
                    carb.log_error(
                        f"Incompatible versions of Python node {node_class.__name__} cannot be registered\n"
                        f"   Generator used {generator_version}, this one is {get_generator_extension_version()}\n"
                        f"   Target was {target_version}, this one is {get_target_extension_version()}\n"
                    )
            except AttributeError as error:
                raise ogn.CarbLogError(f"File structure with Python OGN node is not compatible - {error}")

        # Secondary exception handling to allow simple loop continuation with reporting
        except ogn.CarbLogError as error:
            carb.log_error(str(error))
        except Exception as error:  # noqa: PLW0703
            _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(error)
            carb.log_error(str(error))

    return deregistration_methods


# ================================================================================
@ogt.deprecated_function("Internal use only")
def generate_automatic_test_imports(test_directory: str):
    """
    Scan the test_directory for test cases and (re)generate the __init__.py file for automatic test registration.

    Args:
        test_directory: Path to the test folder. Will be created if it doesn't exist
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Testing for the existence of generated test files")
    ogn.generate_test_imports(Path(test_directory), write_file=True)


# ================================================================================
def register_ogn_nodes(file_requesting_registration: str, import_path: str) -> List[Callable]:
    """
    Scan the generated ogn/ subdirectory for files that look like nodes and import them to force registration

    Also regenerates the tests/__init__.py file for automatic test registration.

    Args:
        file_requesting_registration: Path to the file in the .ogn directory tree
        import_path: Python import path of the file requesting the registration (e.g. omni.graph.core)

    Returns:
        List of methods to deregister all of the node types (to save for extension shutdown)
    """
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
        "Registering OGN nodes in directory {} to path {}", file_requesting_registration, import_path
    )

    # For converting full path names to module import specifications we need the location the import path starts
    (import_root, generated_directory) = find_import_locations(file_requesting_registration, import_path)
    _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Import root {}, generated directory {}", import_root, generated_directory)

    # The top level contains the generated database file, __init__.py, and tests/ directory, as well as
    # links or subdirectories containing .ogn implementations. Gather up the database and .ogn subdirectories.
    (database_files, ogn_directories) = find_ogn_and_python_interfaces(generated_directory)

    # Find .ogn files to check for being up to date
    (ogn_files, node_files) = scan_directories_for_ogn_files(ogn_directories, import_root)

    # Check to see if the Database.py file corresponding to a .ogn file needs updating
    skip_file_generation = ogt.is_unwritable(generated_directory)
    if skip_file_generation:
        _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("Skipping generation in unwritable directory {}", generated_directory)
    else:
        _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("Walking the files needing regeneration in {}", generated_directory)
        for (ogn_path, database_path, exists) in files_needing_regeneration(
            ogn_files, database_files, generated_directory
        ):
            try:
                (new_python_file, new_test_file, new_cpp_file, new_docs_file, new_usd_file) = regenerate_files(
                    ogn_path, database_path, import_path
                )
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Generated python file = {}", new_python_file)
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Generated test file = {}", new_test_file)
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Generated C++ file = {}", new_cpp_file)
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Generated docs file = {}", new_docs_file)
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg("  Generated USD file = {}", new_usd_file)
            except (ogn.NodeGenerationError, PermissionError) as error:
                _ = ogt.OGN_REG_DEBUG and ogt.dbg_reg(
                    "  File could not be written so the existing one will be used - {}", error
                )
                if not exists:
                    carb.log_error(f"Generated files could not be written. Functionality may be missing - {error}")
                skip_file_generation = True

    # Take the list of existing nodes and register all Python nodes with OmniGraph
    deregistration_list = register_existing_python_nodes(node_files, import_path, generated_directory)

    if not skip_file_generation:
        # The tests/ subdirectory is automatically scanned when the module appears in extension.toml.
        # By importing all generated tests classes from there they will be automatically registered.
        test_directory = Path(generated_directory) / "tests"
        ogn.generate_test_imports(Path(test_directory), write_file=True)

    return deregistration_list
