# SPDX-License-Identifier: BSD-2-Clause

# Copyright (c) 2025 Phil Thompson <phil@riverbankcomputing.com>


from abc import abstractmethod
import glob
import os
import shutil
import stat
import sys

from .abstract_builder import AbstractBuilder
from .distinfo import write_metadata
from .exceptions import UserException
from .installable import Installable
from .module import copy_sip_h, copy_sip_pyi, get_latest_version
from .py_versions import DEFAULT_ABI_MAJOR, OLDEST_SUPPORTED_MINOR


class Builder(AbstractBuilder):
    """ The default base implementation of a project builder. """

    def build(self):
        """ Build the project in-situ. """

        self._generate_bindings()
        self._generate_scripts()

        if self.project.compile:
            self.build_project(self.project.target_dir)

    @abstractmethod
    def build_executable(self, buildable, *, fatal=True):
        """ Build an executable from a BuildableExecutable object and return
        the relative pathname of the executable.
        """

    @abstractmethod
    def build_project(self, target_dir, *, wheel_tag=None):
        """ Build the project. """

    def build_sdist(self, sdist_directory):
        """ Build an sdist for the project and return the name of the sdist
        file.
        """

        project = self.project

        # The sdist name.
        sdist_name = project.normalized_name

        # Create the sdist root directory.
        sdist_root = os.path.join(project.build_dir, sdist_name)
        os.mkdir(sdist_root)

        # Get a list of all excluded files.
        excluded = []
        for patt in project.sdist_excludes:
            excluded.extend(glob.glob(os.path.join(project.root_dir, patt)))

        for dname, dirnames, filenames in os.walk(project.root_dir):
            # Always ignore certain directories.
            if dname == project.build_dir:
                del dirnames[:]
                continue

            try:
                dirnames.remove('__pycache__')
            except ValueError:
                pass

            # Copy each file non-excluded file.
            for s_fn in filenames:
                s_fn_path = os.path.join(dname, s_fn)

                if s_fn_path in excluded:
                    continue

                d_fn_path = os.path.join(sdist_root,
                        os.path.relpath(s_fn_path, project.root_dir))
                os.makedirs(os.path.dirname(d_fn_path), exist_ok=True)

                shutil.copy2(s_fn_path, d_fn_path)

        # If we don't know the target ABI at this point then default to the
        # latest version.
        if project.target_abi is None:
            major_version = DEFAULT_ABI_MAJOR
            minor_version = get_latest_version(major_version)
            project.target_abi = (major_version, minor_version)

        # Create the PKG-INFO file.  This is assumed to be identical to the
        # .dist-info/METADATA file.
        write_metadata(project.metadata, project.get_requires_dists(),
                os.path.join(sdist_root, 'PKG-INFO'), project.root_dir)

        # Create the tarball.
        sdist_file = sdist_name + '.tar.gz'
        sdist_path = os.path.abspath(os.path.join(sdist_directory, sdist_file))

        saved_cwd = os.getcwd()
        os.chdir(project.build_dir)

        import tarfile

        tf = tarfile.open(sdist_path, 'w:gz', format=tarfile.PAX_FORMAT)
        tf.add(sdist_name)
        tf.close()

        os.chdir(saved_cwd)

        return sdist_file

    def build_wheel(self, wheel_directory):
        """ Build a wheel for the project and return the name of the wheel
        file.
        """

        project = self.project

        # Create a temporary directory for the wheel.
        wheel_build_dir = os.path.join(project.build_dir, 'wheel')
        os.mkdir(wheel_build_dir)

        # Build the wheel contents.
        self._generate_bindings()

        # Create the wheel tag.  If all buildables use the limited API then the
        # wheel does.
        wheel_tag = []

        if project.all_modules_use_limited_abi:
            # When the ABI tag is 'abi3' the interpreter tag is interpreted as
            # a minimum Python version.  This doesn't seem to be defined in a
            # PEP but is implemented in current pips.
            wheel_tag.append('cp3' + str(OLDEST_SUPPORTED_MINOR))
            wheel_tag.append('abi3')
        else:
            major_minor = '{}{}'.format((sys.hexversion >> 24) & 0xff,
                    (sys.hexversion >> 16) & 0xff)

            wheel_tag.append('cp{}'.format(major_minor))

            try:
                wheel_tag.append('cp' + major_minor + sys.abiflags)
            except AttributeError:
                wheel_tag.append('none')

        wheel_tag.append(project.get_platform_tag())

        wheel_tag = '-'.join(wheel_tag)

        # Add any wheel contents defined by the project.
        for nr, (patt, target_subdir) in enumerate(project.wheel_includes):
            if not os.path.isabs(patt):
                patt = os.path.join(project.root_dir, patt)

            wheel_includes = glob.glob(patt)
            if wheel_includes:
                installable = Installable(
                        'wheel_includes_{}'.format(nr) if nr else 'wheel_includes',
                        target_subdir=target_subdir)
                installable.files.extend(wheel_includes)
                project.installables.append(installable)

        # Build the project.
        self.build_project(wheel_build_dir, wheel_tag=wheel_tag)

        # Copy the wheel contents.
        self.install_project(wheel_build_dir, wheel_tag=wheel_tag)

        wheel_file = [project.normalized_name]

        if project.build_tag:
            wheel_file.append(project.build_tag)

        wheel_file.append(wheel_tag)
        wheel_file = '-'.join(wheel_file) + '.whl'
        wheel_path = os.path.abspath(os.path.join(wheel_directory, wheel_file))

        # Create the .whl file.
        saved_cwd = os.getcwd()
        os.chdir(wheel_build_dir)

        import time 
        from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED

        # Ensure reproducible wheel file timestamps
        epoch = int(os.environ.get('SOURCE_DATE_EPOCH', time.time()))
        zip_timestamp = time.gmtime(epoch)[:6]

        with ZipFile(wheel_path, 'w') as zf:
            for dirpath, _, filenames in os.walk('.'):
                for filename in filenames:
                    # This will result in a name with no leading '.'.
                    name = os.path.relpath(os.path.join(dirpath, filename))

                    zi = ZipInfo(name, zip_timestamp)

                    with open(name, 'rb') as f:
                        zf.writestr(zi, f.read(), compress_type=ZIP_DEFLATED)

        os.chdir(saved_cwd)

        return wheel_file

    def install(self):
        """ Install the project. """

        target_dir = self.project.target_dir

        self._generate_bindings()
        self._generate_scripts()
        self.build_project(target_dir)
        self.install_project(target_dir)

    @abstractmethod
    def install_project(self, target_dir, *, wheel_tag=None):
        """ Install the project into a target directory. """

    def _generate_bindings(self):
        """ Generate the bindings for all enabled modules. """

        project = self.project

        # Get the list of directories to search for .sip files.
        sip_include_dirs = list(project.sip_include_dirs)

        if project.sip_module:
            # Add the project's sip directory.
            sip_include_dirs.append(project.sip_files_dir)

            # Add the local bindings directory to pick up the .toml files for
            # any other bindings in this package.
            local_bindings_dir = os.path.join(project.build_dir, 'bindings')
            sip_include_dirs.append(local_bindings_dir)

            # Add any bindings from previously installed packages.
            sip_include_dirs.append(
                    os.path.join(project.target_dir,
                            project.get_bindings_dir()))

        # Generate the code for each set of bindings.
        api_files = []

        for bindings in project.bindings.values():
            project.progress(
                    "Generating the {0} bindings".format(bindings.name))

            # Generate the source code.  We would prefer to pass the include
            # directories as an argument to generate but this is a fixed public
            # interface.
            bindings._sip_include_dirs = sip_include_dirs
            buildable = bindings.generate()
            del bindings._sip_include_dirs

            if not bindings.internal:
                api_files.append(
                        os.path.join(project.build_dir,
                                buildable.target + '.api'))

                # Generate the bindings configuration file.
                if project.sip_module:
                    buildable.write_configuration(local_bindings_dir)

            project.buildables.append(buildable)

        if project.sip_module:
            # Generate the sip.h file for the shared sip module now that we
            # know the target ABI.
            copy_sip_h(project.build_abi, project.sip_module,
                    project.build_dir, version_info=project.version_info)

        # Create __init__.py if required.
        if project.dunder_init:
            package_dir = project.get_package_dir()

            init_path = os.path.join(project.build_dir, '__init__.py')

            init_f = project.open_for_writing(init_path)
            init_f.write(project.get_dunder_init())
            init_f.close()

            installable = Installable('init', target_subdir=package_dir)
            installable.files.append(init_path)
            project.installables.append(installable)

            # Include sip.pyi if any of the bindings generate a .pyi file.
            for bindings in project.bindings.values():
                if bindings.pep484_pyi:
                    copy_sip_pyi(project.build_abi, project.build_dir)

                    installable = Installable('sip_pyi',
                            target_subdir=package_dir)
                    installable.files.append(
                            os.path.join(project.build_dir, 'sip.pyi'))
                    project.installables.append(installable)

                    # Create a PEP 561 marker file.
                    py_typed_path = os.path.join(project.build_dir, 'py.typed')
                    with open(py_typed_path, 'w') as _:
                        pass

                    installable = Installable('py_typed',
                            target_subdir=package_dir)
                    installable.files.append(py_typed_path)
                    project.installables.append(installable)

                    break

        # Create the .api file if required.
        if project.api_dir:
            api_fn = project.name + '.api'

            project.progress("Generating the {0} file".format(api_fn))

            # Concatanate the individual .api files.
            api_path = os.path.join(project.build_dir, api_fn)
            api_f = project.open_for_writing(api_path)

            for part_fn in api_files:
                with open(part_fn) as part_f:
                    api_f.write(part_f.read())

            api_f.close()

            # Add an Installable for the requested API file.
            installable = Installable('api', target_subdir=project.api_dir)
            installable.files.append(api_path)
            project.installables.append(installable)

    def _generate_scripts(self):
        """ Generate the scripts for any entry points. """

        project = self.project

        # Handle the trivial case.
        scripts = project.console_scripts + project.gui_scripts
        if not scripts:
            return

        # Create an installable for the scripts.
        installable = Installable('scripts', target_subdir=project.scripts_dir)

        for ep in scripts:
            # Parse the entry point.
            ep_parts = ep.replace(' ', '').split('=')

            if len(ep_parts) != 2:
                raise UserException(
                        "'{0}' is an invalid script specification".format(ep))

            script, module = ep_parts

            # Remove any callable name.
            module = module.split(':')[0]

            if project.py_platform == 'win32':
                script += '.bat'

            project.progress("Generating the {} script".format(script))

            script_path = os.path.join(project.build_dir, script)
            installable.files.append(script_path)

            script_f = project.open_for_writing(script_path)

            if project.py_platform == 'win32':
                script_f.write(
                        '@{} -m {} %1 %2 %3 %4 %5 %6 %7 %8 %9\n'.format(
                                sys.executable, module))
            else:
                script_f.write('#!/bin/sh\n')
                script_f.write(
                        'exec %s -m %s ${1+"$@"}\n' % (sys.executable,
                                module))

            script_f.close()

            # Make the script executable.
            os.chmod(script_path,
                    stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR|
                    stat.S_IRGRP|stat.S_IXGRP|
                    stat.S_IROTH|stat.S_IXOTH)

        project.installables.append(installable)
