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

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


import os
import sys

from .buildable import BuildableBindings
from .configurable import Configurable, Option
from .exceptions import UserException
from .generator import parse, resolve
from .generator.outputs import (output_api, output_code, output_extract,
        output_pyi)
from .installable import Installable
from .module import copy_nonshared_sources
from .version import SIP_VERSION


class Bindings(Configurable):
    """ The encapsulation of a module's bindings. """

    # The configurable options.
    _options = (
        # Any bindings level builder-specific settings.
        Option('builder_settings', option_type=list),

        # The list of #define names and values in the format "NAME" or
        # "NAME=VALUE".
        Option('define_macros', option_type=list),

        # Set if exception support is enabled.
        Option('exceptions', option_type=bool),

        # The list of extra compiler arguments.
        Option('extra_compile_args', option_type=list),

        # The list of extra linker arguments.
        Option('extra_link_args', option_type=list),

        # The list of extra compiled object files to link.
        Option('extra_objects', option_type=list),

        # The list of extracts to generate.
        Option('generate_extracts', option_type=list),

        # The list of additional .h files.
        Option('headers', option_type=list),

        # The list of additional C/C++ include directories to search.
        Option('include_dirs', option_type=list),

        # Set if the bindings are internal.  Internal bindings don't have their
        # .sip, .pyi or .api files installed.
        Option('internal', option_type=bool),

        # The list of library names to link against.
        Option('libraries', option_type=list),

        # The list of C/C++ library directories to search.
        Option('library_dirs', option_type=list),

        # Set to always release the Python GIL.
        Option('release_gil', option_type=bool),

        # Set to compile the module as a static library.
        Option('static', option_type=bool),

        # The name of the .sip file that specifies the bindings.  If it is
        # relative then it is relative to the project's 'sip_files_dir'.
        Option('sip_file'),

        # The filename extension to use for generated source files.
        Option('source_suffix'),

        # The list of additional C/C++ source files to compile and link.
        Option('sources', option_type=list),

        # The list of tags to enable.
        Option('tags', option_type=list),

        # The user-configurable options.  Although the use of a corresponding
        # command line option will affect all sets of bindings, putting them
        # here (as opposed to in Project) means they can have individual
        # values specified in pyproject.toml.
        Option('concatenate', option_type=int,
                help="concatenate the generated bindings into N source files",
                metavar="N"),
        Option('debug', option_type=bool, help="build with debugging symbols"),
        Option('disabled_features', option_type=list,
                help="disable the TAG feature tag", metavar="TAG"),
        Option('docstrings', option_type=bool, inverted=True,
                help="disable the generation of docstrings"),
        Option('pep484_pyi', option_type=bool,
                help="enable the generation of PEP 484 .pyi files"),
        Option('protected_is_public', option_type=bool,
                help="enable the protected/public hack (default on non-Windows)"),
        Option('protected_is_public', option_type=bool, inverted=True,
                help="disable the protected/public hack (default on Windows)"),
        Option('tracing', option_type=bool, help="build with tracing support"),
    )

    def __init__(self, project, name, **kwargs):
        """ Initialise the bindings. """

        super().__init__()

        self.project = project
        self.name = name

        self.initialise_options(kwargs)

    def apply_nonuser_defaults(self, tool):
        """ Set default values for each non-user configurable option that
        hasn't been set yet.
        """

        # Provide a default .sip file name if needed.
        if self.sip_file is None:
            self.sip_file = self.name + '.sip'

        super().apply_nonuser_defaults(tool)

    def apply_user_defaults(self, tool):
        """ Set default values for user options that haven't been set yet. """

        if self.protected_is_public is None:
            self.protected_is_public = (self.project.py_platform != 'win32')

        super().apply_user_defaults(tool)

    def generate(self):
        """ Generate the bindings source code and optional additional extracts.
        and return a BuildableBindings instance containing the details of
        everything needed to build the bindings.
        """

        project = self.project

        # The old parser had no concept of the encoding of a .sip file.  For
        # the moment we say that files should be UTF-8.  If that proves to be a
        # problem then a project-specific encoding should be able to be
        # specified in pyproject.toml which would apply to all .sip files that
        # make up the project.
        encoding = 'UTF-8'

        # Parse the input file.
        spec, modules, sip_files = parse(self.sip_file, SIP_VERSION, encoding,
                project.target_abi, self.tags, self.disabled_features,
                self.protected_is_public, self._sip_include_dirs,
                project.sip_module)

        # Update the target ABI for the project.
        if project.target_abi is None or project.target_abi[1] is None or project.target_abi < spec.target_abi:
            project.target_abi = spec.target_abi

        # Resolve the types.
        resolve(spec, modules)

        module = spec.module

        uses_limited_api = module.use_limited_api or spec.is_composite

        # The details of things that will have been generated.  Note that we
        # don't include anything for .api files or generic extracts as the
        # arguments include a file name.
        buildable = BuildableBindings(self, module.fq_py_name.name,
                uses_limited_api=uses_limited_api)

        buildable.builder_settings.extend(self.builder_settings)
        buildable.debug = self.debug
        buildable.exceptions = self.exceptions
        buildable.extra_compile_args = self.extra_compile_args
        buildable.extra_link_args = self.extra_link_args
        buildable.extra_objects = self.extra_objects
        buildable.static = self.static

        # Generate any API file.
        if project.api_dir and not self.internal:
            project.progress(
                    "Generating the {0} .api file".format(buildable.target))

            output_api(spec,
                    os.path.join(project.build_dir, buildable.target + '.api'))

        # Generate any extracts.
        for extract_ref in self.generate_extracts:
            output_extract(spec, extract_ref)

        # Generate any type hints file.
        if self.pep484_pyi and not self.internal:
            project.progress(
                    "Generating the {0} .pyi file".format(buildable.target))

            pyi_path = os.path.join(buildable.build_dir,
                    buildable.target + '.pyi')

            output_pyi(spec, project, pyi_path)

            installable = Installable('pyi',
                    target_subdir=buildable.get_install_subdir())
            installable.files.append(pyi_path)
            buildable.installables.append(installable)

        # Generate the bindings.
        output_code(spec, self, project, buildable)

        buildable.headers.extend(self.headers)

        # Add the sip module code if it is not shared.
        buildable.include_dirs.append(buildable.build_dir)

        if project.sip_module:
            # sip.h will already be in the build directory.
            buildable.include_dirs.append(project.build_dir)

            if not self.internal:
                # Add an installable for the .sip files.
                installable = buildable.get_bindings_installable('sip')

                sip_dir = os.path.dirname(self.sip_file)

                for fn in sip_files:
                    sip_path = os.path.join(sip_dir, fn)

                    # The code generator does not report the full pathname of a
                    # .sip file (only names relative to the search directory in
                    # which it was found).  Therefore we need to check if it is
                    # actually in the directory we are installing from and
                    # ignore it if not.  This isn't really the right thing to
                    # do but is actually what we want when we have optional
                    # license .sip files.
                    if os.path.isfile(sip_path):
                        installable.files.append(sip_path)

                buildable.installables.append(installable)
        else:
            buildable.sources.extend(
                    copy_nonshared_sources(project.build_abi,
                            buildable.build_dir))

        buildable.include_dirs.extend(self.include_dirs)
        buildable.sources.extend(self.sources)

        if self.protected_is_public:
            buildable.define_macros.append('SIP_PROTECTED_IS_PUBLIC')
            buildable.define_macros.append('protected=public')

        buildable.define_macros.extend(self.define_macros)

        buildable.libraries.extend(self.libraries)
        buildable.library_dirs.extend(self.library_dirs)

        return buildable

    def get_options(self):
        """ Return the list of configurable options. """

        options = super().get_options()
        options.extend(self._options)

        return options

    def is_buildable(self):
        """ Return True if the bindings are buildable.  This will not be called
        if the bindings have been explicitly enabled.
        """

        return True

    def verify_configuration(self, tool):
        """ Verify that the configuration is complete and consistent. """

        if tool not in Option.BUILD_TOOLS:
            return

        project = self.project

        super().verify_configuration(tool)

        # Make sure relevent paths are absolute and use native separators.
        self.extra_objects = [project.project_path(o)
                for o in self.extra_objects]
        self.headers = [project.project_path(h) for h in self.headers]
        self.include_dirs = [project.project_path(d)
                for d in self.include_dirs]
        self.library_dirs = [project.project_path(d)
                for d in self.library_dirs]
        self.sip_file = project.project_path(self.sip_file,
                relative_to=project.sip_files_dir)
        self.sources = [project.project_path(s) for s in self.sources]

        # Check the .sip file exists.
        if not os.path.isfile(self.sip_file):
            raise UserException(
                    "the file '{0}' for the {1} bindings does not "
                            "exist".format(self.sip_file, self.name))

        # On Windows the interpreter must be a debug build if a debug version
        # is to be built and vice versa.
        if sys.platform == 'win32':
            if self.debug:
                if not project.py_debug:
                    raise UserException(
                            "A debug version of Python must be used when "
                            "building a debug version of the {0} "
                            "bindings".format(self.name))
            elif project.py_debug:
                raise UserException(
                        "A debug version of the {0} bindings must be built "
                        "when a debug version of Python is used".format(
                                self.name))

        if not self.source_suffix:
            self.source_suffix = None
