#     Copyright 2025, Kay Hayen, mailto:kay.hayen@gmail.com find license text at end of file


"""Postprocessing tasks for create binaries or modules."""

import ctypes
import os
import sys

from nuitka.build.DataComposerInterface import getConstantBlobFilename
from nuitka.freezer.MacOSApp import createPlistInfoFile
from nuitka.ModuleRegistry import getImportedModuleNames
from nuitka.options.Options import (
    getDebuggerName,
    getFileVersionTuple,
    getProductVersionTuple,
    getWindowsIconExecutablePath,
    getWindowsIconPaths,
    getWindowsSplashScreen,
    getWindowsVersionInfoStrings,
    isOnefileMode,
    isStandaloneMode,
    shallAskForWindowsAdminRights,
    shallAskForWindowsUIAccessRights,
    shallCreateAppBundle,
    shallCreatePyiFile,
    shallCreatePyiFileContainStubs,
    shallCreateScriptFileForExecution,
    shallMakeModule,
    shallRunInDebugger,
    shallUseStaticLibPython,
)
from nuitka.OutputDirectories import (
    getMainModule,
    getResultBasePath,
    getResultFullpath,
    getResultRunFilename,
    getSourceDirectoryPath,
)
from nuitka.PythonFlavors import isSelfCompiledPythonUninstalled
from nuitka.PythonVersions import getTargetPythonDLLPath, python_version
from nuitka.States import states
from nuitka.Tracing import postprocessing_logger
from nuitka.utils.Execution import wrapCommandForDebuggerForSubprocess
from nuitka.utils.FileOperations import (
    addFileExecutablePermission,
    deleteFile,
    getFileContents,
    getFileSize,
    hasFilenameExtension,
    makeFilesystemEncodable,
    makePath,
    putTextFileContents,
    removeFileExecutablePermission,
)
from nuitka.utils.Images import convertImageToIconFormat
from nuitka.utils.Importing import importFromInlineCopy
from nuitka.utils.SharedLibraries import (
    callInstallNameTool,
    cleanupHeaderForAndroid,
    getOtoolDependencyOutput,
    parseOtoolListingOutput,
)
from nuitka.utils.Utils import (
    isAIX,
    isAndroidBasedLinux,
    isMacOS,
    isWin32Windows,
)
from nuitka.utils.WindowsResources import (
    RT_GROUP_ICON,
    RT_ICON,
    RT_RCDATA,
    addResourceToFile,
    addVersionInfoResource,
    convertStructureToBytes,
    copyResourcesFromFileToFile,
    getDefaultWindowsExecutableManifest,
    getWindowsExecutableManifest,
)


class IconDirectoryHeader(ctypes.Structure):
    _fields_ = [
        ("reserved", ctypes.c_short),
        ("type", ctypes.c_short),
        ("count", ctypes.c_short),
    ]


class IconDirectoryEntry(ctypes.Structure):
    _fields_ = [
        ("width", ctypes.c_char),
        ("height", ctypes.c_char),
        ("colors", ctypes.c_char),
        ("reserved", ctypes.c_char),
        ("planes", ctypes.c_short),
        ("bit_count", ctypes.c_short),
        ("image_size", ctypes.c_int),
        ("image_offset", ctypes.c_int),
    ]


class IconGroupDirectoryEntry(ctypes.Structure):
    # Make sure the don't have padding issues.
    _pack_ = 2

    _fields_ = (
        ("width", ctypes.c_char),
        ("height", ctypes.c_char),
        ("colors", ctypes.c_char),
        ("reserved", ctypes.c_char),
        ("planes", ctypes.c_short),
        ("bit_count", ctypes.c_short),
        ("image_size", ctypes.c_int),
        ("id", ctypes.c_short),
    )


def readFromFile(readable, c_struct):
    """Read ctypes structures from input."""

    result = c_struct()
    chunk = readable.read(ctypes.sizeof(result))
    ctypes.memmove(ctypes.byref(result), chunk, ctypes.sizeof(result))
    return result


def _addWindowsIconFromIcons(onefile):
    # Relatively detailed handling, pylint: disable=too-many-locals

    icon_group = 1
    image_id = 1
    images = []

    result_filename = getResultFullpath(onefile=onefile, real=False)

    for icon_spec in getWindowsIconPaths():
        if "#" in icon_spec:
            icon_path, icon_index = icon_spec.rsplit("#", 1)
            icon_index = int(icon_index)
        else:
            icon_path = icon_spec
            icon_index = None

        if not hasFilenameExtension(icon_path, ".ico"):
            postprocessing_logger.info(
                "File '%s' is not in Windows icon format, converting to it." % icon_path
            )

            if icon_index is not None:
                return postprocessing_logger.sysexit(
                    "Cannot specify indexes with non-ico format files in '%s'."
                    % icon_spec
                )

            icon_build_path = os.path.join(
                getSourceDirectoryPath(onefile=onefile, create=False),
                "icons",
            )
            makePath(icon_build_path)
            converted_icon_path = os.path.join(
                icon_build_path,
                "icon-%d.ico" % image_id,
            )

            convertImageToIconFormat(
                logger=postprocessing_logger,
                image_filename=icon_spec,
                converted_icon_filename=converted_icon_path,
            )

            icon_path = converted_icon_path

        with open(icon_path, "rb") as icon_file:
            # Read header and icon entries.
            header = readFromFile(icon_file, IconDirectoryHeader)
            icons = [
                readFromFile(icon_file, IconDirectoryEntry)
                for _i in range(header.count)
            ]

            if icon_index is not None:
                if icon_index > len(icons):
                    return postprocessing_logger.sysexit(
                        "Error, referenced icon index %d in file '%s' with only %d icons."
                        % (icon_index, icon_path, len(icons))
                    )

                icons[:] = icons[icon_index : icon_index + 1]

            postprocessing_logger.info(
                "Adding %d icon(s) from icon file '%s'." % (len(icons), icon_spec)
            )

            # Image data are to be scanned from places specified icon entries
            for icon in icons:
                icon_file.seek(icon.image_offset, 0)
                images.append(icon_file.read(icon.image_size))

        parts = [convertStructureToBytes(header)]

        for icon in icons:
            parts.append(
                convertStructureToBytes(
                    IconGroupDirectoryEntry(
                        width=icon.width,
                        height=icon.height,
                        colors=icon.colors,
                        reserved=icon.reserved,
                        planes=icon.planes,
                        bit_count=icon.bit_count,
                        image_size=icon.image_size,
                        id=image_id,
                    )
                )
            )

            image_id += 1

        addResourceToFile(
            target_filename=result_filename,
            data=b"".join(parts),
            resource_kind=RT_GROUP_ICON,
            lang_id=0,
            res_name=icon_group,
            logger=postprocessing_logger,
        )

    for count, image in enumerate(images, 1):
        addResourceToFile(
            target_filename=result_filename,
            data=image,
            resource_kind=RT_ICON,
            lang_id=0,
            res_name=count,
            logger=postprocessing_logger,
        )


def createScriptFileForExecution(result_filename):
    script_filename = getResultRunFilename(onefile=False)

    # TODO: This is probably a prefix kind that should be used more often, e.g.
    # in reporting.
    if isSelfCompiledPythonUninstalled():
        sys_prefix = os.path.dirname(sys.executable)
    else:
        sys_prefix = sys.prefix

    python_home = makeFilesystemEncodable(sys_prefix)
    python_path = os.pathsep.join(makeFilesystemEncodable(e) for e in sys.path)

    debugger_call = (
        (
            " ".join(
                wrapCommandForDebuggerForSubprocess(
                    command=(), debugger=getDebuggerName()
                )
            )
            + " "
        )
        if shallRunInDebugger()
        else ""
    )
    exe_filename = os.path.basename(makeFilesystemEncodable(result_filename))

    if isWin32Windows():
        script_contents = """
@echo off
rem This script was created by Nuitka to execute '%(exe_filename)s' with Python DLL being found.
set PATH=%(dll_directory)s;%%PATH%%
set PYTHONHOME=%(python_home)s
set NUITKA_PYTHONPATH=%(python_path)s
%(debugger_call)s"%%~dp0%(exe_filename)s" %%*
""" % {
            "debugger_call": debugger_call,
            "dll_directory": makeFilesystemEncodable(
                os.path.dirname(getTargetPythonDLLPath())
            ),
            "python_home": python_home,
            "python_path": python_path,
            "exe_filename": exe_filename,
        }
    elif isAIX() and not isOnefileMode() and isStandaloneMode():
        # On AIX we need to set LIBPATH in order to force it to load the DLL from the standalone
        # distribution. The onefile bootstrap does it the code already, and therefore does not
        # need it at all.
        # spell-checker: ignore LIBPATH
        script_contents = """\
#!/bin/sh
# Absolute path to this script, e.g. /home/user/bin/foo.sh
SCRIPT=$(readlink -f "$0")
# Absolute path this script is in, thus /home/user/bin
SCRIPT_PATH=$(dirname "$SCRIPT")

LIBPATH=$SCRIPT_PATH
export LIBPATH

%(debugger_call)s"$SCRIPT_PATH/%(exe_filename)s" $@
""" % {
            "debugger_call": debugger_call,
            "exe_filename": exe_filename,
        }

    else:
        # TODO: Setting PYTHONPATH should not be needed, but it fails to work
        # unlike on Windows for unknown reasons.

        script_contents = """\
#!/bin/sh
# Absolute path to this script, e.g. /home/user/bin/foo.sh
SCRIPT=$(readlink -f "$0")
# Absolute path this script is in, thus /home/user/bin
SCRIPT_PATH=$(dirname "$SCRIPT")

PYTHONHOME=%(python_home)s
export PYTHONHOME
NUITKA_PYTHONPATH="%(python_path)s"
export NUITKA_PYTHONPATH
PYTHONPATH="%(python_path)s"
export PYTHONPATH

%(debugger_call)s"$SCRIPT_PATH/%(exe_filename)s" $@

""" % {
            "debugger_call": debugger_call,
            "python_home": python_home,
            "python_path": python_path,
            "exe_filename": exe_filename,
        }

    putTextFileContents(script_filename, script_contents)

    if not isWin32Windows():
        addFileExecutablePermission(script_filename)


def executePostProcessingResources(result_filename, manifest, onefile):
    """Adding Windows resources to the binary.

    Used for both onefile and not onefile binary, potentially two times.
    """
    if manifest is None:
        manifest = getDefaultWindowsExecutableManifest()

    if shallAskForWindowsAdminRights():
        manifest.addUacAdmin()

    if shallAskForWindowsUIAccessRights():
        manifest.addUacUiAccess()

    manifest.addResourceToFile(result_filename, logger=postprocessing_logger)

    if (
        getWindowsVersionInfoStrings()
        or getProductVersionTuple()
        or getFileVersionTuple()
    ):
        addVersionInfoResource(
            string_values=getWindowsVersionInfoStrings(),
            product_version=getProductVersionTuple(),
            file_version=getFileVersionTuple(),
            file_date=(0, 0),
            is_exe=not shallMakeModule(),
            result_filename=result_filename,
            logger=postprocessing_logger,
        )

    # Attach icons from template file if given.
    template_exe = getWindowsIconExecutablePath()
    if template_exe is not None:
        res_copied = copyResourcesFromFileToFile(
            template_exe,
            target_filename=result_filename,
            resource_kinds=(RT_ICON, RT_GROUP_ICON),
        )

        if res_copied == 0:
            postprocessing_logger.warning(
                "The specified icon template executable '%s' didn't contain anything to copy."
                % template_exe
            )
        else:
            postprocessing_logger.warning(
                "Copied %d icon resources from '%s'." % (res_copied, template_exe)
            )
    else:
        _addWindowsIconFromIcons(onefile=onefile)

    splash_screen_filename = getWindowsSplashScreen()
    if splash_screen_filename is not None:
        splash_data = getFileContents(splash_screen_filename, mode="rb")

        addResourceToFile(
            target_filename=result_filename,
            data=splash_data,
            resource_kind=RT_RCDATA,
            lang_id=0,
            res_name=28,
            logger=postprocessing_logger,
        )


def _createModulePyiFile():
    if shallCreatePyiFileContainStubs():
        source_code = getMainModule().getSourceCode()

        if source_code is None:
            stubgen = None
            stubgen_reason = "Namespace packages have no code to use stubgen on"
        elif python_version >= 0x300:
            stubgen = importFromInlineCopy("stubgen", must_exist=False)
            stubgen_reason = "Stubs included by default"
        else:
            stubgen = None
            stubgen_reason = "Python2 is not supported for stubs"
    else:
        source_code = None
        stubgen = None
        stubgen_reason = "Stubs were disabled"

    imported_module_names = getImportedModuleNames()

    contents = """\
# This file was generated by Nuitka
"""

    if stubgen is not None:
        try:
            stubs = stubgen.generate_stub_from_source(
                source_code=source_code,
                output_file_path=None,
                text_only=True,
            )
        except Exception as e:  # pylint: disable=broad-exception-caught
            postprocessing_logger.warning(
                """\
Stub generation with internal stubgen tool failed due to: '%s'.

Please report the module code in an issue report."""
                % (str(e))
            )

            if states.is_debug:
                raise

            stubs = None
            stubgen_reason = (
                "Stub generation failed due to error, please report the issue."
            )
    else:
        stubs = None

    stubs = "# %s\n%s" % (stubgen_reason, "" if stubs is None else stubs)

    contents += "\n" + stubs + "\n"

    contents += "__name__ = ..."

    imported_module_names = getImportedModuleNames()

    # Meaningless, TODO: Potentially others could be listed here too.
    if "__future__" in imported_module_names:
        imported_module_names.remove("__future__")

    contents += "\n" * 4

    if imported_module_names:
        contents += (
            "# Modules used internally, to allow implicit dependencies to be seen:\n"
        )
        contents += "\n".join(
            "import %s" % module_name for module_name in getImportedModuleNames()
        )
    else:
        contents += "# No other modules used internally, no implicit dependencies.\n"

    pyi_filename = getResultBasePath() + ".pyi"
    putTextFileContents(pyi_filename, contents, encoding="utf-8")


def executePostProcessing(result_filename):
    """Postprocessing of the resulting binary.

    These are in part required steps, not usable after failure.
    """

    # Lots of cases to deal with,
    # pylint: disable=too-many-branches

    if isWin32Windows():
        if not shallMakeModule():
            if python_version < 0x300:
                # Copy the Windows manifest from the CPython binary to the created
                # executable, so it finds "MSCRT.DLL". This is needed for Python2
                # only, for Python3 newer MSVC doesn't hide the C runtime.
                manifest = getWindowsExecutableManifest(sys.executable)
            else:
                manifest = None

            executePostProcessingResources(
                result_filename=result_filename, manifest=manifest, onefile=False
            )

        source_dir = getSourceDirectoryPath(onefile=False, create=True)

        # Attach the binary blob as a Windows resource.
        addResourceToFile(
            target_filename=result_filename,
            data=getFileContents(getConstantBlobFilename(source_dir), mode="rb"),
            resource_kind=RT_RCDATA,
            res_name=3,
            lang_id=0,
            logger=postprocessing_logger,
        )

    # On macOS, we update the executable path for searching the "libpython"
    # library.
    if isMacOS() and not shallMakeModule() and not shallUseStaticLibPython():
        for dependency in parseOtoolListingOutput(
            getOtoolDependencyOutput(result_filename)
        ):
            if os.path.basename(dependency).lower().startswith(("python", "libpython")):
                python_dll_filename = dependency
                break
        else:
            return postprocessing_logger.sysexit(
                """
Error, expected 'libpython dependency not found. Please report the bug."""
            )

        python_lib_path = os.path.dirname(python_dll_filename)
        python_dll_path = python_dll_filename

        if not os.path.exists(python_lib_path):
            python_lib_path = os.path.join(sys.prefix, "lib")
            python_dll_path = os.path.join(
                python_lib_path, os.path.basename(python_dll_filename)
            )

        # Note: For CPython, and potentially others, the rpath for the Python
        # library needs to be set, so it will be detected as a dependency
        # without tricks.
        callInstallNameTool(
            filename=result_filename,
            mapping=(
                (
                    python_dll_filename,
                    python_dll_path,
                ),
            ),
            id_path=None,
            rpath=python_lib_path,
        )

    if shallCreateAppBundle():
        createPlistInfoFile(logger=postprocessing_logger)

    # Modules should not be executable, but Scons creates them like it, fix
    # it up here.
    if not isWin32Windows() and shallMakeModule():
        removeFileExecutablePermission(result_filename)

    if isWin32Windows() and shallMakeModule():
        candidate = os.path.join(
            os.path.dirname(result_filename),
            "lib" + os.path.basename(result_filename)[:-4] + ".a",
        )

        deleteFile(candidate, must_exist=False)

    if isAndroidBasedLinux():
        cleanupHeaderForAndroid(result_filename)

    # Might have to create a CMD file, potentially with debugger run.
    if shallCreateScriptFileForExecution():
        createScriptFileForExecution(result_filename=result_filename)

    # Create a ".pyi" file for created modules
    if shallMakeModule() and shallCreatePyiFile():
        _createModulePyiFile()

    if isWin32Windows() and getFileSize(result_filename) > 2**30 * 1.8:
        postprocessing_logger.warning(
            """\
The created compiled binary is larger than 1.8GB and therefore may not be
executable by Windows due to its limitations."""
        )


#     Part of "Nuitka", an optimizing Python compiler that is compatible and
#     integrates with CPython, but also works on its own.
#
#     Licensed under the GNU Affero General Public License, Version 3 (the "License");
#     you may not use this file except in compliance with the License.
#     You may obtain a copy of the License at
#
#        http://www.gnu.org/licenses/agpl.txt
#
#     Unless required by applicable law or agreed to in writing, software
#     distributed under the License is distributed on an "AS IS" BASIS,
#     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#     See the License for the specific language governing permissions and
#     limitations under the License.
