from .importer import Modulex
from .path.utils import (
    __move__,
    __dump__,
    __append__,
    __hashfile__,
    __compress__,
    __load__,
    list_path,
    make,
    tools_module as filetools,
)
from .ImporterException import DirNotExistsError, NotAFile, NotADictionary, BuiltInPkg
from ._cache._cache_data import DataCache
from .path.metafile import getmetafilepkg, getmetapath
from ._builtin import isbuiltin
from ._tracker import CallsTracker
from ._compare import ModuleComparator

from pathlib import Path
import shutil
import time
import sys
import json
import importlib
import inspect
import re
from typing import Optional, List, Dict, Any, Tuple, Set, Callable
from types import ModuleType
from concurrent.futures import ThreadPoolExecutor


def keys(DictObject):
    return DictObject.keys()


def values(DictObject):
    return DictObject.values()


class pmeX:  # Python Module Explorer
    """
    A comprehensive Python module exploration and manipulation tool.

    This class provides extensive capabilities for analyzing, modifying, and
    managing Python modules at runtime including file operations, code injection,
    feature management, and module state control.
    """

    def __init__(self, ModuleName: str) -> None:
        """
        Initialize the module explorer with a target module name.

        Args:
                ModuleName (str): Name of the module to explore and manage

        Raises:
                TypeError: If ModuleName is not a string

        Attributes:
                modx (Modulex): Internal module loader and manager
                time1 (float): Initial module loading time measurement
                name (str): Original module name provided
        """
        if not isinstance(ModuleName, str):
            raise TypeError(
                f"expected ModuleName str, got <{type(ModuleName).__name__}>"
            )
        s = time.perf_counter()
        self.modx = Modulex(ModuleName)
        self.time1 = time.perf_counter() - s  # First load time
        self.name = ModuleName

    def _check_file(self, filename: Path) -> True:
        """
        Validate that a path exists and is a file.

        Args:
                filename (Path): Path object to validate

        Returns:
                True: If validation passes

        Raises:
                FileNotFoundError: If file doesn't exist
                NotAFile: If path is not a file
        """
        if not filename.exists():
            raise FileNotFoundError(
                f"filename is not found in current path '{Path.cwd()}'"
            )
        if not filename.is_file():
            raise NotAFile("expected file")
        return True

    def _check_dir(self, dirname: Path) -> True:
        """
        Validate that a path exists and is a directory.

        Args:
                dirname (Path): Path object to validate

        Returns:
                True: If validation passes

        Raises:
                DirNotExistsError: If directory doesn't exist
                NotADictionary: If path is not a directory
        """
        if not dirname.exists():
            raise DirNotExistsError(
                f"dirname is not found in current path '{Path.cwd()}'"
            )
        if not dirname.is_dir():
            raise NotADictionary("excpected dir")
        return True

    def dump(
        self, code, filename: str, dirname: str = None, exist_ok: bool = False
    ) -> None:
        """
        Dump code content to a file within the module directory structure.

        Args:
                code: Content to write to file
                filename (str): Target filename
                dirname (str, optional): Subdirectory within module. Defaults to module root.
                exist_ok (bool, optional): Whether to allow overwriting existing files. Defaults to False.

        Raises:
                FileExistsError: If file exists and exist_ok is False
        """
        pathname = (
            self.modx.path() / Path(dirname) if dirname else self.modx.path()
        )  # Note:: self.modx.path() return pathlib.Path object

        if self._check_dir(pathname):
            pathfile = Path(filename)
            fullpathname = pathname / pathfile

            if fullpathname.exists() and not exist_ok:
                raise FileExistsError(f"filename is in module path '{fullpathname}'")

            __dump__(pathfile, code)
            __move__(pathfile, pathname)

    def dump_code(self, code: str, target: str, rewrite: bool = False) -> None:
        """
        Append code to an existing file within the module.

        Args:
                code (str): Python code to append
                target (str): Target file path within module
        """
        fulltargetname = self.modx.path() / Path(target)
        if self._check_file(fulltargetname):
            if rewrite:
                __dump__(fulltargetname, code)
                return
            __append__(fulltargetname, "\n" + code)

    def remove(self, pathname: str) -> None:
        """
        Remove a file or directory from the module.

        Args:
                pathname (str): Path to remove (relative to module root)
        """
        fullpathname = self.modx.path() / Path(pathname)
        try:
            if self._check_file(fullpathname):
                fullpathname.unlink()
        except:
            if self._check_dir(fullpathname):
                shutil.rmtree(fullpathname)

    def rmroot(self):
        """
        Remove the main root in the library
        """
        if isbuiltin(self.name):
            raise BuiltInPkg(f"'{self.name}' is built-in package")
        root = self.modx.path()
        if self.name == root.name:
            shutil.rmtree(root)
        try:
            metapath = getmetapath(self.name)
        except FileNotFoundError:
            metapath = None
        if metapath:
            shutil.rmtree(metapath)

    def copy(self, pathname: str, path: str = None) -> None:
        """
        Copy a file or directory from module to another location.

        Args:
                                                                        pathname (str): Source path within module
                                                                        path (str, optional): Destination path. Defaults to current working directory.
        """
        fullpathname = self.modx.path() / Path(pathname)
        copypath = Path(path if path else Path.cwd())

        try:
            if self._check_file(fullpathname):
                shutil.copy2(str(fullpathname), str(copypath / fullpathname.name))
        except:
            if self._check_dir(fullpathname):
                shutil.copytree(
                    str(fullpathname),
                    str(copypath / fullpathname.name),
                    copy_function=shutil.copy2,
                )

    def snapshot(self, algo: str = "md5", max: int = None) -> dict:
        """
        Create a cryptographic snapshot of module files for integrity checking.

        Args:
                                                                        algo (str, optional): Hash algorithm to use. Defaults to "md5".
                                                                        max (int, optional): Maximum number of files to process. Defaults to None (all files).

        Returns:
                                                                        dict: Mapping of file paths to their hash values
        """
        with ThreadPoolExecutor() as executor:
            results = list(
                executor.map(
                    lambda f: (str(f), __hashfile__(f, algo)),
                    self.modx.files()[:max],
                )
            )
        return dict(results)

    def verify(self, old_snapshot: dict, algo: str = "md5"):
        """
        Compare current module file hashes with a previous snapshot to detect modifications.

        Args:
                                                                        old_snapshot (dict): The previous snapshot mapping {Path: hash}
                                                                        algo (str): Hash algorithm used for comparison (default: md5)

        Returns:
                                                                        dict: Report of integrity check with modified, added, deleted, and unmodified files
        """
        new_snapshot = self.snapshot(algo, error=False)
        modified = {}
        added = []
        deleted = []
        unmodified = 0

        for file, old_hash in old_snapshot.items():
            if file not in new_snapshot:
                deleted.append(str(file))
            elif new_snapshot[file] != old_hash:
                modified[str(file)] = {
                    "old": old_hash,
                    "new": new_snapshot[file],
                }
            else:
                unmodified += 1

        for file in new_snapshot.keys():
            if file not in old_snapshot:
                added.append(str(file))

        return {
            "modified": modified,
            "added": added,
            "deleted": deleted,
            "unmodified": unmodified,
            "total_files": len(new_snapshot),
        }

    @staticmethod
    def calls_tracker(**kwargs) -> CallsTracker:
        return CallsTracker(**kwargs)

    def compare(self, other: str, **kwargs) -> ModuleComparator:
        return ModuleComparator(self.modx.name, other_path=other, **kwargs)

    def inject(self, code_str: str) -> dict:
        """
        Inject Python code into a module's namespace at runtime.

        Dynamically executes code within the module's context, allowing runtime
        modification of module attributes, functions, and classes.

        Args:
                                                                        code_str (str): Python source code to execute in module namespace

        Returns:
                                                                        dict: Summary of changes with keys:
                                                                                                                                        - "added": List of newly added attribute names
                                                                                                                                        - "changed": List of modified attribute names

        Side Effects:
                                                                        - Creates/updates module.__inject_backup__ to track original values
                                                                        - Creates/updates module.__inject_added__ to track new attributes
        """
        module = self.modx.init

        if not hasattr(module, "__inject_backup__"):
            module.__inject_backup__ = {}
        if not hasattr(module, "__inject_added__"):
            module.__inject_added__ = set()

        ns = module.__dict__
        old_keys = set(ns.keys())
        old_values = {k: ns[k] for k in old_keys}
        exec(code_str, ns)
        new_keys = set(ns.keys())
        added = new_keys - old_keys
        changed = {k for k in (new_keys & old_keys) if ns[k] is not old_values[k]}

        module.__inject_added__.update(added)
        for name in changed:
            if name not in module.__inject_backup__:
                module.__inject_backup__[name] = old_values[name]

        return {"added": sorted(added), "changed": sorted(changed)}

    def revert_injection(self, name: str = None, restore_all: bool = False) -> dict:
        """
        Revert injections previously made by inject_code.

        Restores original module state by either reverting specific injections
        or all recorded injections.

        Args:
                                                                        name (str, optional): Specific attribute name to revert
                                                                        restore_all (bool, optional): Whether to revert all injections. Defaults to False.

        Returns:
                                                                        dict: Summary of revert operations with keys:
                                                                                                                                        - "restored": List of restored attribute names
                                                                                                                                        - "deleted": List of deleted attribute names
        """
        module = self.modx.init

        ns = module.__dict__
        if not hasattr(module, "__inject_backup__") and not hasattr(
            module, "__inject_added__"
        ):
            return {"restored": [], "deleted": []}

        restored = []
        deleted = []

        def _restore(n):
            """Internal helper to restore a single attribute"""
            if hasattr(module, "__inject_backup__") and n in module.__inject_backup__:
                ns[n] = module.__inject_backup__.pop(n)
                restored.append(n)
            elif hasattr(module, "__inject_added__") and n in module.__inject_added__:
                module.__inject_added__.remove(n)
                if n in ns:
                    del ns[n]
                deleted.append(n)

        if restore_all:
            for n in list(getattr(module, "__inject_backup__", {}).keys()):
                _restore(n)
            for n in list(getattr(module, "__inject_added__", set())):
                _restore(n)
        else:
            if name is None:
                return {"restored": [], "deleted": []}
            _restore(name)

        return {"restored": restored, "deleted": deleted}

    def time_load(self) -> dict:
        """
        Measure module loading performance characteristics.

        Compares initial load time (from constructor) with subsequent reload time
        to analyze caching and import optimization effects.

        Returns:
                        dict: Loading time metrics with keys:
                                        - "load1": Initial load time
                                        - "load2": Subsequent load time
                                        - "diff": Difference between load times (load1 - load2)
        """
        start = time.perf_counter()
        importlib.import_module(self.name)
        time2 = time.perf_counter() - start

        return {
            "load1": self.time1,
            "load2": time2,
            "diff": self.time1 - time2,
        }

    def disable_feature(
        self, name: str, behavior: str = "raise", message: str = None
    ) -> dict:
        """
        Disable a feature (function or class) in the module at runtime.

        Args:
                                                                        name (str): Attribute name to disable (must exist in module)
                                                                        behavior (str, optional): Disable behavior mode. Options:
                                                                                                                                        - "raise": Disabled callable raises RuntimeError (default)
                                                                                                                                        - "noop": Disabled callable returns None
                                                                                                                                        - "stub": Replace class with stub that raises on instantiation
                                                                        message (str, optional): Custom error message for raise behavior

        Returns:
                                                                        dict: Disable operation summary with keys:
                                                                                                                                        - "disabled": Name of disabled feature
                                                                                                                                        - "original_type": Type of disabled feature ("function", "class", or "other")

        Raises:
                                                                        AttributeError: If specified feature doesn't exist in module

        Side Effects:
                                                                        - Creates/updates module.__disable_backup__ to store original values
        """
        module = self.modx.init
        if not hasattr(module, name):
            raise AttributeError(f"Module '{self.name}' has no attribute '{name}'")

        orig = getattr(module, name)
        # ensure backup dict exists
        if not hasattr(module, "__disable_backup__"):
            module.__disable_backup__ = {}

        # don't overwrite existing backup for same name
        if name not in module.__disable_backup__:
            module.__disable_backup__[name] = orig

        msg = message or f"Feature '{name}' is disabled."

        # function-like
        if callable(orig) and not isinstance(orig, type):
            if behavior == "noop":

                def _noop(*args, **kwargs):
                    """Disabled function stub that returns None"""
                    return None

                setattr(module, name, _noop)
            else:  # default raise

                def _raise(*args, **kwargs):
                    """Disabled function stub that raises RuntimeError"""
                    raise RuntimeError(msg)

                setattr(module, name, _raise)
            return {"disabled": name, "original_type": "function"}

        # class
        if isinstance(orig, type):
            if behavior == "noop":
                # stub class that constructs but does nothing
                class _NoopClass:
                    """Disabled class stub that does nothing"""

                    def __init__(self, *a, **k):
                        return None

                setattr(module, name, _NoopClass)
            else:
                # stub class that raises on instantiation or attribute access
                class _DisabledClass:
                    """Disabled class stub that raises RuntimeError"""

                    def __init__(self, *a, **k):
                        raise RuntimeError(msg)

                    def __getattribute__(self, item):
                        raise RuntimeError(msg)

                setattr(module, name, _DisabledClass)
            return {"disabled": name, "original_type": "class"}

        # other objects (constants, modules, etc.) -> replace with None but keep backup
        setattr(module, name, None)
        return {"disabled": name, "original_type": "other"}

    def revert_feature(self, name: str = None, restore_all: bool = False) -> dict:
        """
        Revert disabled features previously disabled by disable_feature.

        Args:
                                                                        name (str, optional): Specific attribute to restore
                                                                        restore_all (bool, optional): Whether to restore all disabled features

        Returns:
                                                                        dict: Restore operation summary with keys:
                                                                                                                                        - "restored": List of successfully restored attributes
                                                                                                                                        - "missing": List of attributes not found in backup
        """
        module = self.modx.init
        if not hasattr(module, "__disable_backup__"):
            return {"restored": [], "missing": []}

        restored = []
        missing = []

        def _restore_one(n):
            """Internal helper to restore a single feature"""
            if n in module.__disable_backup__:
                setattr(module, n, module.__disable_backup__.pop(n))
                restored.append(n)
            else:
                missing.append(n)

        if restore_all:
            for n in list(module.__disable_backup__.keys()):
                _restore_one(n)
        else:
            if name is None:
                return {"restored": [], "missing": []}
            _restore_one(name)

        return {"restored": restored, "missing": missing}

    def describe(self) -> dict:
        """
        Generate a type description of all module attributes.

        Returns:
                                                                        dict: Mapping of attribute names to their type names
        """
        module = self.modx.init
        attrs = {}
        for name, val in module.__dict__.items():
            attrs[name] = type(val).__name__
        return attrs

    def freeze(self, message: str) -> None:
        """
        Make the module completely immutable by preventing attribute modifications.

        Args:
                                                                        message (str): Error message to display on modification attempts

        Side Effects:
                                                                        - Sets module.__frozen__ = True
                                                                        - Replaces module.__setattr__ with raising stub
        """
        module = self.modx.init
        module.__frozen__ = True
        orig_setattr = module.__setattr__
        msg = message or f"Module '{module.__name__}' is frozen and cannot be modified"

        def _locked(*a, **k):
            """Replacement __setattr__ that prevents modifications"""
            raise RuntimeError(msg)

        module.__setattr__ = _locked

    def init_module(self, tools_module: bool = True) -> ModuleType:
        """
        Prepare and normalize a Python package directory for development or deployment.

        This method verifies that the target module directory is correctly structured
        and free of redundant cache folders, ensuring it can be imported or built
        without issues. It performs several automated maintenance operations.

        Main Actions
        ------------
        1. **Remove all "__pycache__" folders** recursively inside the module directory.
        2. **Ensure every valid source directory has an "__init__.py"** file, so Python
           recognizes it as a package.
        3. **Optionally add essential project files** (via `filetools()`) such as
           "setup.py", "pyproject.toml", or "requirements.txt" to the root directory.

        Args:
                                                                        tools_module (bool, default=True): Automatically add default project files
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          defined by `filetools()` if missing.

        Returns:
                                                                        ModuleType: Reloaded and initialized module instance

        Raises:
                                                                        FileNotFoundError: If the target module directory cannot be resolved

        Note:
                                                                        - This method modifies files and directories directly on disk
                                                                        - Suitable for cleaning, preparing, or rebuilding Python project structure
        """
        module = self.modx.init
        try:
            module_path = Path(module.__path__[0])
        except Exception:
            raise FileNotFoundError(f"Path '{self.name}' not found")

        for path in module_path.rglob("*"):
            # Remove __pycache__ directories
            if (
                path.is_dir()
                and path.name.lower() == "__pycache__"
                and module_path in path.parents
            ):
                shutil.rmtree(path)

            # Add __init__.py to directories containing .py files
            elif path.is_dir():
                files = list_path(path)
                if (
                    any(f.name.endswith(".py") for f in files)
                    and "__init__.py" not in files
                ):
                    __move__(make("__init__.py"), path)

        # Add missing project tool files
        if tools_module:
            for filetool in filetools():
                if filetool not in list_path(module_path):
                    __move__(make(filetool), module_path)

        return importlib.reload(self.modx.init)

    def executive(self, code: str):
        """
        Execute or evaluate Python code inside a module's namespace.

        Intelligently handles both expressions and statements:
        - Expressions are evaluated with eval() and return result
        - Statements are executed with exec() and return None

        Args:
                                                                        code (str): Python code to execute in module context

        Returns:
                                                                        Result of expression evaluation, or None for statements
        """
        # Prepare the environment (namespace) for the model
        ns = self.modx.init.__dict__

        try:
            # First try to analyze the code as an expression
            result = eval(code, ns)
            return result
        except SyntaxError:
            # If it can't be evaluated as an expression, consider it code and implement it
            exec(code, ns)
            return None

    def readonly(self, freeze=False) -> None:
        """
        Freeze the module by wrapping its functions, classes, and attributes.

        Provides two levels of protection:
        - freeze=False: Makes attributes read-only but preserves functionality
        - freeze=True: Completely disables functionality with error stubs

        Args:
                                                                        freeze (bool, optional): Protection level. Defaults to False.
                                                                                                                                        - False: Read-only access to original functionality
                                                                                                                                        - True: Complete lockdown with RuntimeError on usage

        Side Effects:
                                                                        - Creates module.__readonly_backup__ to store original attributes
                                                                        - Replaces module attributes with protected versions
                                                                        - Preserves internal attributes (starting with '__')

        Note:
                                                                        Once applied, the module cannot be modified or extended until reverted
        """
        module = self.modx.init

        # Create backup storage for original attributes
        if not hasattr(module, "__readonly_backup__"):
            module.__readonly_backup__ = {}

        for name in dir(module):
            if name.startswith("__"):
                continue  # Ignore internal attributes

            if name in module.__readonly_backup__:
                continue  # Skip already backed-up attributes

            try:
                attr = getattr(module, name)
            except Exception:
                continue  # Skip lazy-loaded or broken attributes

            # Store original attribute
            module.__readonly_backup__[name] = attr

            if freeze:
                # Wrap functions
                if callable(attr) and not isinstance(attr, type):

                    def _raise_fn(*args, **kwargs):
                        """Frozen function stub that raises RuntimeError"""
                        raise RuntimeError(
                            f"Function '{name}' is readonly and cannot be modified or called"
                        )

                    setattr(module, name, _raise_fn)

                # Wrap classes
                elif isinstance(attr, type):

                    class _DisabledClass:
                        """Frozen class stub that raises RuntimeError"""

                        def __init__(self, *a, **k):
                            raise RuntimeError(
                                f"Class '{name}' is readonly and cannot be instantiated"
                            )

                        def __getattribute__(self, item):
                            raise RuntimeError(f"Class '{name}' is readonly")

                    setattr(module, name, _DisabledClass)

                # Wrap other attributes (constants, objects)
                else:
                    setattr(module, name, property(lambda self: attr))
            else:
                # If freeze=False, only make attributes read-only but keep functionality
                if callable(attr) and not isinstance(attr, type):
                    # Keep function callable but prevent modification
                    setattr(module, name, property(lambda self: attr))
                elif isinstance(attr, type):
                    # Keep class accessible but prevent instantiation and modification
                    class _ReadonlyClass:
                        """Read-only class wrapper preserving inspection capabilities"""

                        def __init__(self, *a, **k):
                            raise RuntimeError(
                                f"Class '{name}' is readonly and cannot be instantiated"
                            )

                        def __getattribute__(self, item):
                            # Allow access to class methods and attributes for inspection
                            if item in [
                                "__class__",
                                "__name__",
                                "__module__",
                                "__doc__",
                            ]:
                                return super().__getattribute__(item)
                            raise RuntimeError(
                                f"Class '{name}' is readonly - cannot access instance attributes"
                            )

                    # Preserve class metadata
                    _ReadonlyClass.__name__ = attr.__name__
                    _ReadonlyClass.__module__ = attr.__module__
                    _ReadonlyClass.__doc__ = attr.__doc__
                    setattr(module, name, _ReadonlyClass)
                else:
                    # Make non-callable attributes read-only
                    setattr(module, name, property(lambda self: attr))

    def validate_imports(self, max: int = None):
        """
        Validate Python imports extracted from a module's code.

        This function checks which imports are available in the current environment
        and which are missing. It handles both standard `import module` and
        `from module import name` statements.

        Parameters
        ----------
        max : int, optional
                                        Maximum number files to process (depends on self.modx.imports implementation).
                                        Default is None (process all files).

        Returns
        -------
        dict
                                        A dictionary containing two keys:
                                        - 'valid_imports': list of successfully imported module names.
                                        - 'invalid_imports': list of module names that failed to import.

        Behavior
        --------
        1. Iterates over `import` statements (self.modx.imports()['import']).
           Each module name is tested via importlib.import_module.
           Valid modules are added to 'valid_imports', invalid to 'invalid_imports'.

        2. Iterates over `from ... import ...` statements (self.modx.imports()['from']).
           Each imported name is combined with its module (module.name) if module exists.
           Each full import path is tested via importlib.import_module.
           Valid imports go to 'valid_imports', invalid to 'invalid_imports'.

        Example
        -------
        >>> validator = MyValidator(modx)
        >>> validator.validate_imports()
        {'valid_imports': ['os', 'sys', 'json.dump', 'json.load'],
         'invalid_imports': ['nonexistentmodule']}

        Note:
                                        - This function will be average speed for small modules (<200 file)
                                        - It will be slow for large modules (>600 file)
        """
        invalid_imports = []
        valid_imports = []

        data = self.modx.structure().imports(max=max)

        # Validate 'import' statements
        for imp in data["import"]:
            try:
                importlib.import_module(imp)
                valid_imports.append(imp)
            except ImportError:
                invalid_imports.append(imp)

        # Validate 'from ... import ...' statements
        for module, names in data["from"].items():
            for name in names:
                full_name = f"{module}.{name}" if module else name
                try:
                    importlib.import_module(full_name)
                    valid_imports.append(full_name)
                except ImportError:
                    invalid_imports.append(full_name)

        return {"valid": valid_imports, "invalid": invalid_imports}
