#-----------------------------------------------------------------------------
# Copyright (c) 2013-2023, PyInstaller Development Team.
#
# Distributed under the terms of the GNU General Public License (version 2
# or later) with exception for distributing the bootloader.
#
# The full license is in the file COPYING.txt, distributed with this software.
#
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------

import os
import fnmatch

from PyInstaller import compat
from PyInstaller import isolated
from PyInstaller import log as logging
from PyInstaller.depend import bindepend

if compat.is_darwin:
    from PyInstaller.utils import osx as osxutils

logger = logging.getLogger(__name__)


@isolated.decorate
def _get_tcl_tk_info():
    """
    Isolated-subprocess helper to retrieve the basic Tcl/Tk information:
     - tkinter_extension_file = the value of __file__ attribute of the _tkinter binary extension (path to file).
     - tcl_data_dir = path to the Tcl library/data directory.
     - tcl_version = Tcl version
     - tk_version = Tk version
     - tcl_theaded = boolean indicating whether Tcl/Tk is built with multi-threading support.
    """
    try:
        import tkinter
        import _tkinter
    except ImportError:
        # tkinter unavailable
        return None
    try:
        tcl = tkinter.Tcl()
    except tkinter.TclError:  # e.g. "Can't find a usable init.tcl in the following directories: ..."
        return None

    # Query the location of Tcl library/data directory.
    tcl_data_dir = tcl.eval("info library")

    # Check if Tcl/Tk is built with multi-threaded support (built with --enable-threads), as indicated by the presence
    # of optional `threaded` member in `tcl_platform` array. Tcl 9.0 removed the --enable-threads flag, and is always
    # built with multi-threaded support (and thus the `threaded` array member has been removed).
    TCL_MAJOR = int(_tkinter.TCL_VERSION.split(".")[0])
    if TCL_MAJOR >= 9:
        tcl_threaded = True
    else:
        try:
            tcl.getvar("tcl_platform(threaded)")  # Ignore the actual value.
            tcl_threaded = True
        except tkinter.TclError:
            tcl_threaded = False

    return {
        "available": True,
        # If `_tkinter` is a built-in (as opposed to an extension), it does not have a `__file__` attribute.
        "tkinter_extension_file": getattr(_tkinter, '__file__', None),
        "tcl_version": _tkinter.TCL_VERSION,
        "tk_version": _tkinter.TK_VERSION,
        "tcl_threaded": tcl_threaded,
        "tcl_data_dir": tcl_data_dir,
    }


class TclTkInfo:
    # Root directory names of Tcl and Tk library/data directories in the frozen application. These directories are
    # originally fully versioned (e.g., tcl8.6 and tk8.6); we want to remap them to unversioned variants, so that our
    # run-time hook (pyi_rthook__tkinter.py) does not have to determine version numbers when setting `TCL_LIBRARY`
    # and `TK_LIBRARY` environment variables.
    #
    # We also cannot use plain "tk" and "tcl", because on macOS, the Tcl and Tk shared libraries might come from
    # framework bundles, and would therefore end up being collected as "Tcl" and "Tk" in the top-level application
    # directory, causing clash due to filesystem being case-insensitive by default.
    TCL_ROOTNAME = '_tcl_data'
    TK_ROOTNAME = '_tk_data'

    def __init__(self):
        pass

    def __repr__(self):
        return "TclTkInfo"

    # Delay initialization of Tcl/Tk information until until the corresponding attributes are first requested.
    def __getattr__(self, name):
        if 'available' in self.__dict__:
            # Initialization was already done, but requested attribute is not available.
            raise AttributeError(name)

        # Load Qt library info...
        self._load_tcl_tk_info()
        # ... and return the requested attribute
        return getattr(self, name)

    def _load_tcl_tk_info(self):
        logger.info("%s: initializing cached Tcl/Tk info...", self)

        # Initialize variables so that they might be accessed even if tkinter/Tcl/Tk is unavailable or if initialization
        # fails for some reason.
        self.available = False
        self.tkinter_extension_file = None
        self.tcl_version = None
        self.tk_version = None
        self.tcl_threaded = False
        self.tcl_data_dir = None

        self.tk_data_dir = None
        self.tcl_module_dir = None

        self.is_macos_system_framework = False
        self.tcl_shared_library = None
        self.tk_shared_library = None

        self.data_files = []

        try:
            tcl_tk_info = _get_tcl_tk_info()
        except Exception as e:
            logger.warning("%s: failed to obtain Tcl/Tk info: %s", self, e)
            return

        # If tkinter could not be imported, `_get_tcl_tk_info` returns None. In such cases, emit a debug message instead
        # of a warning, because this initialization might be triggered by a helper function that is trying to determine
        # availability of `tkinter` by inspecting the `available` attribute.
        if tcl_tk_info is None:
            logger.debug("%s: failed to obtain Tcl/Tk info: tkinter/_tkinter could not be imported.", self)
            return

        # Copy properties
        for key, value in tcl_tk_info.items():
            setattr(self, key, value)

        # Parse Tcl/Tk version into (major, minor) tuple.
        self.tcl_version = tuple((int(x) for x in self.tcl_version.split(".")[:2]))
        self.tk_version = tuple((int(x) for x in self.tk_version.split(".")[:2]))

        # Determine full path to Tcl and Tk shared libraries against which the `_tkinter` extension module is linked.
        # This can only be done when `_tkinter` is in fact an extension, and not a built-in. In the latter case, the
        # Tcl/Tk libraries are statically linked into python shared library, so there are no shared libraries for us
        # to discover.
        if self.tkinter_extension_file:
            try:
                (
                    self.tcl_shared_library,
                    self.tk_shared_library,
                ) = self._find_tcl_tk_shared_libraries(self.tkinter_extension_file)
            except Exception:
                logger.warning("%s: failed to determine Tcl and Tk shared library location!", self, exc_info=True)

            # macOS: check if _tkinter is linked against system-provided Tcl.framework and Tk.framework. This is the
            # case with python3 from XCode tools (and was the case with very old homebrew python builds). In such cases,
            # we should not be collecting Tcl/Tk files.
            if compat.is_darwin:
                self.is_macos_system_framework = self._check_macos_system_framework(self.tcl_shared_library)

                # Emit a warning in the unlikely event that we are dealing with Teapot-distributed version of ActiveTcl.
                if not self.is_macos_system_framework:
                    self._warn_if_using_activetcl_or_teapot(self.tcl_data_dir)

        # Infer location of Tk library/data directory. Ideally, we could infer this by running
        #
        # import tkinter
        # root = tkinter.Tk()
        # tk_data_dir = root.tk.exprstring('$tk_library')
        #
        # in the isolated subprocess as part of `_get_tcl_tk_info`. However, that is impractical, as it shows the empty
        # window, and on some platforms (e.g., linux) requires display server. Therefore, try to guess the location,
        # based on the following heuristic:
        #  - if TK_LIBRARY is defined use it.
        #  - if Tk is built as macOS framework bundle, look for Scripts sub-directory in Resources directory next to
        #    the shared library.
        #  - otherwise, look for: $tcl_root/../tkX.Y, where X and Y are Tk major and minor version.
        if "TK_LIBRARY" in os.environ:
            self.tk_data_dir = os.environ["TK_LIBRARY"]
        elif compat.is_darwin and self.tk_shared_library and (
            # is_framework_bundle_lib handles only fully-versioned framework library paths...
            (osxutils.is_framework_bundle_lib(self.tk_shared_library)) or
            # ... so manually handle top-level-symlinked variant for now.
            (self.tk_shared_library).endswith("Tk.framework/Tk")
        ):
            # Fully resolve the library path, in case it is a top-level symlink; for example, resolve
            # /Library/Frameworks/Python.framework/Versions/3.13/Frameworks/Tk.framework/Tk
            # into
            # /Library/Frameworks/Python.framework/Versions/3.13/Frameworks/Tk.framework/Versions/8.6/Tk
            tk_lib_realpath = os.path.realpath(self.tk_shared_library)
            # Resources/Scripts directory next to the shared library
            self.tk_data_dir = os.path.join(os.path.dirname(tk_lib_realpath), "Resources", "Scripts")
        else:
            self.tk_data_dir = os.path.join(
                os.path.dirname(self.tcl_data_dir),
                f"tk{self.tk_version[0]}.{self.tk_version[1]}",
            )

        # Infer location of Tcl module directory. The modules directory is separate from the library/data one, and
        # is located at $tcl_root/../tclX, where X is the major Tcl version. In some Tcl distributions (for example,
        # Debian-packaged Tcl), this directory is located under library/data directory ($tcl_root); in such cases,
        # we do not need to worry about it.
        self.tcl_module_dir = os.path.join(
            os.path.dirname(self.tcl_data_dir),
            f"tcl{self.tcl_version[0]}",
        )

        # Find all data files
        if self.is_macos_system_framework:
            logger.info("%s: using macOS system Tcl/Tk framework - not collecting data files.", self)
        else:
            # Collect Tcl and Tk scripts from their corresponding library/data directories. See comment at the
            # definition of TK_ROOTNAME and TK_ROOTNAME variables.
            if os.path.isdir(self.tcl_data_dir):
                self.data_files += self._collect_files_from_directory(
                    self.tcl_data_dir,
                    prefix=self.TCL_ROOTNAME,
                    excludes=['demos', '*.lib', 'tclConfig.sh'],
                )
            else:
                logger.warning("%s: Tcl library/data directory %r does not exist!", self, self.tcl_data_dir)

            if os.path.isdir(self.tk_data_dir):
                self.data_files += self._collect_files_from_directory(
                    self.tk_data_dir,
                    prefix=self.TK_ROOTNAME,
                    excludes=['demos', '*.lib', 'tkConfig.sh'],
                )
            else:
                logger.warning("%s: Tk library/data directory %r does not exist!", self, self.tk_data_dir)

            # Collect Tcl modules from optional modules directory
            if os.path.isdir(self.tcl_module_dir):
                self.data_files += self._collect_files_from_directory(
                    self.tcl_module_dir,
                    prefix=os.path.basename(self.tcl_module_dir),
                )

    @staticmethod
    def _collect_files_from_directory(root, prefix=None, excludes=None):
        """
        A minimal port of PyInstaller.building.datastruct.Tree() functionality, which allows us to avoid using Tree
        here. This way, the TclTkInfo data structure can be used without having PyInstaller's config context set up.
        """
        excludes = excludes or []

        todo = [(root, prefix)]
        output = []
        while todo:
            target_dir, prefix = todo.pop()

            for entry in os.listdir(target_dir):
                # Basic name-based exclusion
                if any((fnmatch.fnmatch(entry, exclude) for exclude in excludes)):
                    continue

                src_path = os.path.join(target_dir, entry)
                dest_path = os.path.join(prefix, entry) if prefix else entry

                if os.path.isdir(src_path):
                    todo.append((src_path, dest_path))
                else:
                    # Return 3-element tuples with fully-resolved dest path, since other parts of code depend on that.
                    output.append((dest_path, src_path, 'DATA'))

        return output

    @staticmethod
    def _find_tcl_tk_shared_libraries(tkinter_ext_file):
        """
        Find Tcl and Tk shared libraries against which the _tkinter extension module is linked.
        """
        tcl_lib = None
        tk_lib = None

        for _, lib_path in bindepend.get_imports(tkinter_ext_file):  # (name, fullpath) tuple
            if lib_path is None:
                continue  # Skip unresolved entries

            # For comparison, take basename of lib_path. On macOS, lib_name returned by get_imports is in fact
            # referenced name, which is not necessarily just a basename.
            lib_name = os.path.basename(lib_path)
            lib_name_lower = lib_name.lower()  # lower-case for comparisons

            # First check for Tk library, because it is unlikely that 'tk' will appear in the name of the Tcl shared
            # library, while 'tcl' could appear in the name of the Tk shared library. For example, Fedora 43 ships
            # both Tcl/Tk 8.6 and 9.0, and in the latter, the libraries are named `libtcl9.0.so` and `libtcl9tk9.0.so`.
            if 'tk' in lib_name_lower:
                tk_lib = lib_path
            elif 'tcl' in lib_name_lower:
                tcl_lib = lib_path

        return tcl_lib, tk_lib

    @staticmethod
    def _check_macos_system_framework(tcl_shared_lib):
        # Starting with macOS 11, system libraries are hidden (unless both Python and PyInstaller's bootloader are built
        # against macOS 11.x SDK). Therefore, Tcl shared library might end up unresolved (None); but that implicitly
        # indicates that the system framework is used.
        if tcl_shared_lib is None:
            return True

        # Check if the path corresponds to the system framework, i.e., [/System]/Library/Frameworks/Tcl.framework/Tcl
        return 'Library/Frameworks/Tcl.framework' in tcl_shared_lib

    @staticmethod
    def _warn_if_using_activetcl_or_teapot(tcl_root):
        """
        Check if Tcl installation is a Teapot-distributed version of ActiveTcl, and log a non-fatal warning that the
        resulting frozen application will (likely) fail to run on other systems.

        PyInstaller does *not* freeze all ActiveTcl dependencies -- including Teapot, which is typically ignorable.
        Since Teapot is *not* ignorable in this case, this function warns of impending failure.

        See Also
        -------
        https://github.com/pyinstaller/pyinstaller/issues/621
        """
        if tcl_root is None:
            return

        # Read the "init.tcl" script and look for mentions of "activetcl" and "teapot"
        init_tcl = os.path.join(tcl_root, 'init.tcl')
        if not os.path.isfile(init_tcl):
            return

        mentions_activetcl = False
        mentions_teapot = False

        # Tcl/Tk reads files using the system encoding (https://www.tcl.tk/doc/howto/i18n.html#system_encoding);
        # on macOS, this is UTF-8.
        with open(init_tcl, 'r', encoding='utf8') as fp:
            for line in fp.readlines():
                line = line.strip().lower()
                if line.startswith('#'):
                    continue
                if 'activetcl' in line:
                    mentions_activetcl = True
                if 'teapot' in line:
                    mentions_teapot = True
                if mentions_activetcl and mentions_teapot:
                    break

        if mentions_activetcl and mentions_teapot:
            logger.warning(
                "You appear to be using an ActiveTcl build of Tcl/Tk, which PyInstaller has\n"
                "difficulty freezing. To fix this, comment out all references to 'teapot' in\n"
                f"{init_tcl!r}\n"
                "See https://github.com/pyinstaller/pyinstaller/issues/621 for more information."
            )


tcltk_info = TclTkInfo()
