#!/usr/bin/env python

__license__   = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import errno
import glob
import json
import os
import shlex
import shutil
import subprocess
import sys
import sysconfig
import textwrap
from functools import partial
from typing import NamedTuple

from setup import SRC, Command, isbsd, isfreebsd, ishaiku, islinux, ismacos, iswindows

isunix = islinux or ismacos or isbsd or ishaiku

py_lib = os.path.join(sys.prefix, 'libs', 'python{}{}.lib'.format(*sys.version_info[:2]))


class CompileCommand(NamedTuple):
    cmd: list[str]
    src: str
    dest: str


class LinkCommand(NamedTuple):
    cmd: list[str]
    objects: list[str]
    dest: str


def walk(path='.'):
    for dirpath, dirnames, filenames in os.walk(path):
        for f in filenames:
            yield os.path.join(dirpath, f)


def init_symbol_name(name):
    prefix = 'PyInit_'
    return prefix + name


def absolutize(paths):
    return list({x if os.path.isabs(x) else os.path.join(SRC, x.replace('/', os.sep)) for x in paths})


class Extension:

    def __init__(self, name, sources, **kwargs):
        self.data = d = {}
        self.name = d['name'] = name
        self.sources = d['sources'] = absolutize(sources)
        self.needs_cxx = d['needs_cxx'] = bool([1 for x in self.sources if os.path.splitext(x)[1] in ('.cpp', '.c++', '.cxx')])
        self.needs_py2 = d['needs_py2'] = kwargs.get('needs_py2', False)
        self.headers = d['headers'] = absolutize(kwargs.get('headers', []))
        self.sip_files = d['sip_files'] = absolutize(kwargs.get('sip_files', []))
        self.needs_exceptions = d['needs_exceptions'] = kwargs.get('needs_exceptions', False)
        self.qt_modules = d['qt_modules'] = kwargs.get('qt_modules', ['widgets'])
        self.inc_dirs = d['inc_dirs'] = absolutize(kwargs.get('inc_dirs', []))
        self.lib_dirs = d['lib_dirs'] = absolutize(kwargs.get('lib_dirs', []))
        self.extra_objs = d['extra_objs'] = absolutize(kwargs.get('extra_objs', []))
        self.error = d['error'] = kwargs.get('error', None)
        self.libraries = d['libraries'] = kwargs.get('libraries', [])
        self.cflags = d['cflags'] = kwargs.get('cflags', [])
        self.uses_icu = 'icuuc' in self.libraries
        self.ldflags = d['ldflags'] = kwargs.get('ldflags', [])
        self.optional = d['options'] = kwargs.get('optional', False)
        self.needs_cxx_std = kwargs.get('needs_c++')
        self.needs_c_std = kwargs.get('needs_c')
        self.only_build_for = kwargs.get('only', '')


def lazy_load(name):
    name = name.removeprefix('!')
    from setup import build_environment
    try:
        return getattr(build_environment, name)
    except AttributeError:
        raise ImportError(f'The setup.build_environment module has no symbol named: {name}')


def expand_file_list(items, is_paths=True, cross_compile_for='native'):
    if not items:
        return []
    ans = []
    for item in items:
        if item.startswith('!'):
            if cross_compile_for == 'native' or not item.endswith('_dirs'):
                item = lazy_load(item)
                if hasattr(item, 'rjust'):
                    item = [item]
                items = expand_file_list(item, is_paths=is_paths, cross_compile_for=cross_compile_for)
            else:
                pkg, category = item[1:].split('_')[:2]
                if category == 'inc':
                    category = 'include'
                items = [f'bypy/b/windows/64/{pkg}/{category}']
                items = expand_file_list(item, is_paths=is_paths, cross_compile_for=cross_compile_for)
            ans.extend(items)
        elif '*' in item:
            ans.extend(expand_file_list(sorted(glob.glob(os.path.join(SRC, item))), is_paths=is_paths, cross_compile_for=cross_compile_for))
        else:
            item = [item]
            if is_paths:
                item = absolutize(item)
            ans.extend(item)
    return ans


def is_ext_allowed(cross_compile_for: str, ext: Extension) -> bool:
    only = ext.only_build_for
    if only:
        if islinux and only == cross_compile_for:
            return True
        only = set(only.split())
        q = set(filter(lambda x: globals()['is' + x], ['bsd', 'freebsd', 'haiku', 'linux', 'macos', 'windows']))
        return len(q.intersection(only)) > 0
    return True


def parse_extension(ext, compiling_for='native'):
    ext = ext.copy()
    only = ext.pop('only', None)
    kw = {}
    name = ext.pop('name')
    get_key = 'linux_'
    if iswindows:
        get_key = 'windows_'
    elif ismacos:
        get_key = 'macos_'
    elif isbsd:
        get_key = 'bsd_'
    elif isfreebsd:
        get_key = 'freebsd_'
    elif ishaiku:
        get_key = 'haiku_'
    if compiling_for == 'windows':
        get_key = 'windows_'

    def get(k, default=''):
        ans = ext.pop(k, default)
        ans = ext.pop(get_key + k, ans)
        return ans
    for k in 'libraries qt_private ldflags cflags error'.split():
        kw[k] = expand_file_list(get(k).split(), is_paths=False)
    defines = get('defines')
    if defines:
        if 'cflags' not in kw:
            kw['cflags'] = []
        cflags = kw['cflags']
        prefix = '/D' if get_key == 'windows_' else '-D'
        cflags.extend(prefix + x for x in defines.split())
    for k in 'inc_dirs lib_dirs sources headers sip_files'.split():
        v = get(k)
        if v:
            kw[k] = expand_file_list(v.split())
    kw.update(ext)
    kw['only'] = only
    return Extension(name, **kw)


def read_extensions():
    if hasattr(read_extensions, 'extensions'):
        return read_extensions.extensions
    with open(os.path.dirname(os.path.abspath(__file__)) + '/extensions.json', 'rb') as f:
        ans = read_extensions.extensions = json.load(f)
    return ans


def get_python_include_paths():
    ans = []
    for name in sysconfig.get_path_names():
        if 'include' in name:
            ans.append(name)

    def gp(x):
        return sysconfig.get_path(x)

    return sorted(frozenset(filter(None, map(gp, sorted(ans)))))


is_macos_universal_build = ismacos and 'universal2' in sysconfig.get_platform()


def basic_windows_flags(debug=False):
    cflags = '/c /nologo /W3 /EHsc /O2 /utf-8'.split()
    cflags.append('/Zi' if debug else '/DNDEBUG')
    suffix = ('d' if debug else '')
    cflags.append('/MD' + suffix)
    ldflags = f'/DLL /nologo /INCREMENTAL:NO /NODEFAULTLIB:libcmt{suffix}.lib'.split()
    if debug:
        ldflags.append('/DEBUG')
    # cflags = '/c /nologo /Ox /MD /W3 /EHsc /Zi'.split()
    # ldflags = '/DLL /nologo /INCREMENTAL:NO /DEBUG'.split()
    cflags.append('/GS-')
    return cflags, ldflags


class Environment(NamedTuple):
    cc: str
    cxx: str
    linker: str
    base_cflags: list[str]
    base_cxxflags: list[str]
    base_ldflags: list[str]
    cflags: list[str]
    ldflags: list[str]
    make: str
    internal_inc_prefix: str
    external_inc_prefix: str
    libdir_prefix: str
    lib_prefix: str
    lib_suffix: str
    obj_suffix: str
    cc_input_c_flag: str
    cc_input_cpp_flag: str
    cc_output_flag: str
    platform_name: str
    dest_ext: str
    std_prefix: str

    def inc_dirs_to_cflags(self, dirs) -> list[str]:
        return [self.external_inc_prefix+x for x in dirs]

    def lib_dirs_to_ldflags(self, dirs) -> list[str]:
        return [self.libdir_prefix+x for x in dirs if x]

    def libraries_to_ldflags(self, libs):
        def map_name(x):
            if '/' in x:
                return x
            return self.lib_prefix+x+self.lib_suffix
        return list(map(map_name, libs))


def init_env(debug=False, sanitize=False, compiling_for='native'):
    from setup.build_environment import NMAKE, win_cc, win_inc, win_ld, win_lib
    linker = None
    internal_inc_prefix = external_inc_prefix = '-I'
    libdir_prefix = '-L'
    lib_prefix = '-l'
    lib_suffix = ''
    std_prefix = '-std='
    obj_suffix = '.o'
    cc_input_c_flag = cc_input_cpp_flag = '-c'
    cc_output_flag = '-o'
    platform_name = 'linux'
    dest_ext = '.so'
    if isunix:
        cc = os.environ.get('CC', 'gcc')
        cxx = os.environ.get('CXX', 'g++')
        debug = '-ggdb' if debug else ''
        cflags = os.environ.get('OVERRIDE_CFLAGS',
            f'-Wall -DNDEBUG {debug} -fno-strict-aliasing -pipe -O3')
        cflags = shlex.split(cflags) + ['-fPIC']
        ldflags = os.environ.get('OVERRIDE_LDFLAGS', '-Wall')
        ldflags = shlex.split(ldflags)
        base_cflags = shlex.split(os.environ.get('CFLAGS', ''))
        base_cxxflags = shlex.split(os.environ.get('CXXFLAGS', ''))
        base_ldflags = shlex.split(os.environ.get('LDFLAGS', ''))
        cflags += base_cflags
        ldflags += base_ldflags
        cflags += ['-fvisibility=hidden']
        if sanitize:
            cflags.append('-fsanitize=address')

    if islinux:
        cflags.append('-pthread')
        if sys.stdout.isatty():
            base_cflags.append('-fdiagnostics-color=always')
            cflags.append('-fdiagnostics-color=always')
        ldflags.append('-shared')

    if isbsd:
        cflags.append('-pthread')
        ldflags.append('-shared')

    if ishaiku:
        cflags.append('-lpthread')
        ldflags.append('-shared')

    if islinux or isbsd or ishaiku:
        cflags.extend('-I' + x for x in get_python_include_paths())
        ldlib = sysconfig.get_config_var('LIBDIR')
        if ldlib:
            ldflags += ['-L' + ldlib]
        ldlib = sysconfig.get_config_var('VERSION')
        if ldlib:
            ldflags += ['-lpython' + ldlib + sys.abiflags]
        ldflags += (sysconfig.get_config_var('LINKFORSHARED') or '').split()

    if ismacos:
        platform_name = 'macos'
        if is_macos_universal_build:
            cflags.extend(['-arch', 'x86_64', '-arch', 'arm64'])
            ldflags.extend(['-arch', 'x86_64', '-arch', 'arm64'])
        cflags.append('-D_OSX')
        ldflags.extend('-bundle -undefined dynamic_lookup'.split())
        cflags.extend(['-fno-common', '-dynamic'])
        cflags.extend('-I' + x for x in get_python_include_paths())

    if iswindows or compiling_for == 'windows':
        platform_name = 'windows'
        std_prefix = '/std:'
        cc = cxx = win_cc
        linker = win_ld
        cflags, ldflags = basic_windows_flags(debug)
        base_cflags, base_cxxflags, base_ldflags = [], [], []
        if compiling_for == 'windows':
            cc = cxx = 'clang-cl'
            linker = 'lld-link'
            splat = '.build-cache/xwin/root'
            cflags.append('-fcolor-diagnostics')
            cflags.append('-fansi-escape-codes')
            for I in 'sdk/include/um sdk/include/cppwinrt sdk/include/shared sdk/include/ucrt crt/include sdk/include/winrt'.split():
                cflags.append('/external:I')
                cflags.append(f'{splat}/{I}')
            for L in 'sdk/lib/um crt/lib sdk/lib/ucrt'.split():
                ldflags.append(f'/libpath:{splat}/{L}')
            for L in os.environ.get('CROSS_LIBDIRS', '').split(os.pathsep):
                ldflags.append(f'/libpath:{L}')
        else:
            for p in win_inc:
                cflags.append('-I'+p)
            for p in win_lib:
                if p:
                    ldflags.append('/LIBPATH:'+p)
        internal_inc_prefix = external_inc_prefix = '/I'
        libdir_prefix = '/libpath:'
        lib_prefix = ''
        lib_suffix = '.lib'
        cc_input_c_flag = '/Tc'
        cc_input_cpp_flag = '/Tp'
        cc_output_flag = '/Fo'
        obj_suffix = '.obj'
        dest_ext = '.pyd'
        if compiling_for == 'windows':
            external_inc_prefix = '/external:I'
            dest_ext = '.cross-windows-x64' + dest_ext
            obj_suffix = '.cross-windows-x64' + obj_suffix
            cflags.append('/external:I')
            cflags.append('bypy/b/windows/64/pkg/python/private/python/include')
            ldflags.append('/libpath:' + 'bypy/b/windows/64/pkg/python/private/python/libs')
        else:
            cflags.extend('-I' + x for x in get_python_include_paths())
            ldflags.append('/LIBPATH:'+os.path.join(sysconfig.get_config_var('prefix'), 'libs'))
    return Environment(
        platform_name=platform_name, dest_ext=dest_ext, std_prefix=std_prefix,
        base_cflags=base_cflags, base_cxxflags=base_cxxflags, base_ldflags=base_ldflags,
        cc=cc, cxx=cxx, cflags=cflags, ldflags=ldflags, linker=linker, make=NMAKE if iswindows else 'make', lib_prefix=lib_prefix,
        obj_suffix=obj_suffix, cc_input_c_flag=cc_input_c_flag, cc_input_cpp_flag=cc_input_cpp_flag, cc_output_flag=cc_output_flag,
        internal_inc_prefix=internal_inc_prefix, external_inc_prefix=external_inc_prefix, libdir_prefix=libdir_prefix, lib_suffix=lib_suffix)


class Build(Command):

    short_description = 'Build calibre C/C++ extension modules'
    DEFAULT_OUTPUTDIR = os.path.abspath(os.path.join(SRC, 'calibre', 'plugins'))
    DEFAULT_BUILDDIR = os.path.abspath(os.path.join(os.path.dirname(SRC), 'build'))

    description = textwrap.dedent('''\
        calibre depends on several python extensions written in C/C++.
        This command will compile them. You can influence the compile
        process by several environment variables, listed below:

           CC      - C Compiler defaults to gcc
           CXX     - C++ Compiler, defaults to g++
           CFLAGS  - Extra compiler flags
           LDFLAGS - Extra linker flags

           POPPLER_INC_DIR - poppler header files
           POPPLER_LIB_DIR - poppler-qt4 library

           PODOFO_INC_DIR - podofo header files
           PODOFO_LIB_DIR - podofo library files

           QMAKE          - Path to qmake
        ''')

    def add_options(self, parser):
        choices = [e['name'] for e in read_extensions()]+['all', 'headless']
        parser.add_option('-1', '--only', choices=choices, default='all',
                help=('Build only the named extension. Available: '+ ', '.join(choices)+'. Default:%default'))
        parser.add_option('--no-compile', default=False, action='store_true',
                help='Skip compiling all C/C++ extensions.')
        parser.add_option('--build-dir', default=None,
            help='Path to directory in which to place object files during the build process, defaults to "build"')
        parser.add_option('--output-dir', default=None,
            help='Path to directory in which to place the built extensions. Defaults to src/calibre/plugins')
        parser.add_option('--debug', default=False, action='store_true',
            help='Build in debug mode')
        parser.add_option('--sanitize', default=False, action='store_true',
            help='Build with sanitization support. Run with LD_PRELOAD=$(gcc -print-file-name=libasan.so)')
        parser.add_option('--cross-compile-extensions', choices='windows disabled'.split(), default='disabled',
            help=('Cross compile extensions for other platforms. Useful for development.'
                ' Currently supports of windows extensions on Linux. Remember to run ./setup.py xwin first to install the Windows SDK locally. '))

    def dump_db(self, name, db):
        os.makedirs('build', exist_ok=True)
        existing = []
        try:
            with open(f'build/{name}_commands.json', 'rb') as f:
                existing = json.load(f)
        except FileNotFoundError:
            pass
        combined = {x['output']: x for x in existing}
        for x in db:
            combined[x['output']] = x
        try:
            with open(f'build/{name}_commands.json', 'w') as f:
                json.dump(tuple(combined.values()), f, indent=2)
        except OSError as err:
            if err.errno != errno.EROFS:
                raise

    def run(self, opts):
        from setup.parallel_build import create_job, parallel_build
        if opts.no_compile:
            self.info('--no-compile specified, skipping compilation')
            return
        self.compiling_for = 'native'
        if islinux and opts.cross_compile_extensions == 'windows':
            self.compiling_for = 'windows'
            if not os.path.exists('.build-cache/xwin/root'):
                subprocess.check_call([sys.executable, 'setup.py', 'xwin'])
        self.env = init_env(debug=opts.debug)
        self.windows_cross_env = init_env(debug=opts.debug, compiling_for='windows')
        all_extensions = tuple(map(partial(parse_extension, compiling_for=self.compiling_for), read_extensions()))
        self.build_dir = os.path.abspath(opts.build_dir or self.DEFAULT_BUILDDIR)
        self.output_dir = os.path.abspath(opts.output_dir or self.DEFAULT_OUTPUTDIR)
        self.obj_dir = os.path.join(self.build_dir, 'objects')
        for x in (self.output_dir, self.obj_dir):
            os.makedirs(x, exist_ok=True)
        pyqt_extensions, extensions = [], []
        for ext in all_extensions:
            if opts.only not in {'all', ext.name}:
                continue
            if not is_ext_allowed(self.compiling_for, ext):
                continue
            if ext.error:
                if ext.optional:
                    self.warn(ext.error)
                    continue
                else:
                    raise Exception(ext.error)
            (pyqt_extensions if ext.sip_files else extensions).append(ext)

        jobs = []
        objects_map = {}
        self.info(f'Building {len(extensions)+len(pyqt_extensions)} extensions')
        ccdb = []
        for ext in all_extensions:
            if ext in pyqt_extensions:
                continue
            cmds, objects = self.get_compile_commands(ext, ccdb)
            objects_map[id(ext)] = objects
            if ext in extensions:
                for cmd in cmds:
                    jobs.append(create_job(cmd.cmd))
        self.dump_db('compile', ccdb)
        if jobs:
            self.info(f'Compiling {len(jobs)} files...')
            if not parallel_build(jobs, self.info):
                raise SystemExit(1)
        jobs, link_commands, lddb = [], [], []
        for ext in all_extensions:
            if ext in pyqt_extensions:
                continue
            objects = objects_map[id(ext)]
            cmd = self.get_link_command(ext, objects, lddb)
            if ext in extensions and cmd is not None:
                link_commands.append(cmd)
                jobs.append(create_job(cmd.cmd))
        self.dump_db('link', lddb)
        if jobs:
            self.info(f'Linking {len(jobs)} files...')
            if not parallel_build(jobs, self.info):
                raise SystemExit(1)
            for cmd in link_commands:
                self.post_link_cleanup(cmd)

        jobs = []
        sbf_map = {}
        for ext in pyqt_extensions:
            cmd, sbf, cwd = self.get_sip_commands(ext)
            sbf_map[id(ext)] = sbf
            if cmd is not None:
                jobs.append(create_job(cmd, cwd=cwd))
        if jobs:
            self.info(f'SIPing {len(jobs)} files...')
            if not parallel_build(jobs, self.info):
                raise SystemExit(1)
        for ext in pyqt_extensions:
            sbf = sbf_map[id(ext)]
            if not os.path.exists(sbf):
                self.build_pyqt_extension(ext, sbf)

        if opts.only in {'all', 'headless'}:
            self.build_headless()

    def dest(self, ext, env):
        return os.path.join(self.output_dir, getattr(ext, 'name', ext))+env.dest_ext

    def env_for_compilation_db(self, ext):
        if is_ext_allowed('native', ext):
            return self.env
        if ext.only_build_for == 'windows':
            return self.windows_cross_env

    def get_compile_commands(self, ext, db):
        obj_dir = self.j(self.obj_dir, ext.name)

        def get(src: str, env: Environment, for_tooling: bool = False) -> CompileCommand:
            compiler = env.cxx if ext.needs_cxx else env.cc
            obj = self.j(obj_dir, os.path.splitext(self.b(src))[0]+env.obj_suffix)
            inf = env.cc_input_cpp_flag if src.endswith(('.cpp', '.cxx')) else env.cc_input_c_flag
            sinc = [inf, src]
            if env.cc_output_flag.startswith('/'):
                if for_tooling:  # clangd gets confused by cl.exe style source and output flags
                    oinc = ['-o', obj]
                else:
                    oinc = [env.cc_output_flag + obj]
                    sinc = [inf + src]
            else:
                oinc = [env.cc_output_flag, obj]
            einc = env.inc_dirs_to_cflags(ext.inc_dirs)
            if env.cc_output_flag.startswith('/'):
                cflags = ['/DCALIBRE_MODINIT_FUNC=PyMODINIT_FUNC']
            else:
                return_type = 'PyObject*'
                extern_decl = 'extern "C"' if ext.needs_cxx else ''
                cflags = [
                    '-DCALIBRE_MODINIT_FUNC='
                    f'{extern_decl} __attribute__ ((visibility ("default"))) {return_type}']
            if ext.needs_cxx and ext.needs_cxx_std:
                if env.cc_output_flag.startswith('/') and ext.needs_cxx == '11':
                    ext.needs_cxx = '14'
                cflags.append(env.std_prefix + 'c++' + ext.needs_cxx_std)

            if ext.needs_c_std and not env.std_prefix.startswith('/'):
                cflags.append(env.std_prefix + 'c' + ext.needs_c_std)

            cmd = [compiler] + env.cflags + cflags + ext.cflags + einc + sinc + oinc
            return CompileCommand(cmd, src, obj)

        objects = []
        ans = []
        os.makedirs(obj_dir, exist_ok=True)

        for src in ext.sources:
            cc = get(src, self.windows_cross_env if self.compiling_for == 'windows' else self.env)
            objects.append(cc.dest)
            if self.newer(cc.dest, [src]+ext.headers):
                ans.append(cc)
            env = self.env_for_compilation_db(ext)
            if env is not None:
                cc = get(src, env, for_tooling=True)
                db.append({
                    'arguments': cc.cmd, 'directory': os.getcwd(), 'file': os.path.relpath(src, os.getcwd()),
                    'output': os.path.relpath(cc.dest, os.getcwd())})
        return ans, objects

    def get_link_command(self, ext, objects, lddb):

        def get(env: Environment) -> LinkCommand:
            dest = self.dest(ext, env)
            compiler = env.cxx if ext.needs_cxx else env.cc
            linker = env.linker or compiler
            cmd = [linker]
            elib = env.lib_dirs_to_ldflags(ext.lib_dirs)
            xlib = env.libraries_to_ldflags(ext.libraries)
            all_objects = sorted(objects + ext.extra_objs)
            if iswindows or env is self.windows_cross_env:
                pre_ld_flags = []
                if ext.uses_icu:
                    # windows has its own ICU libs that don't work
                    pre_ld_flags = elib
                cmd += pre_ld_flags + env.ldflags + ext.ldflags + elib + xlib + \
                    ['/EXPORT:' + init_symbol_name(ext.name)] + all_objects + ['/OUT:'+dest]
            else:
                cmd += all_objects + ['-o', dest] + env.ldflags + ext.ldflags + elib + xlib
            return LinkCommand(cmd, objects, dest)

        env = self.env_for_compilation_db(ext)
        if env is not None:
            ld = get(env)
            lddb.append({'arguments': ld.cmd, 'directory': os.getcwd(), 'output': os.path.relpath(ld.dest, os.getcwd())})

        env = self.windows_cross_env if self.compiling_for == 'windows' else self.env
        lc = get(env)
        if self.newer(lc.dest, objects+ext.extra_objs):
            return lc

    def post_link_cleanup(self, link_command):
        if iswindows:
            dest = link_command.dest
            for x in ('.exp', '.lib'):
                x = os.path.splitext(dest)[0]+x
                if os.path.exists(x):
                    os.remove(x)

    def check_call(self, *args, **kwargs):
        '''print cmdline if an error occurred

        If something is missing (cmake e.g.) you get a non-informative error
         self.check_call(qmc + [ext.name+'.pro'])
         so you would have to look at the source to see the actual command.
        '''
        try:
            subprocess.check_call(*args, **kwargs)
        except Exception:
            cmdline = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in args[0]])
            print(f'Error while executing: {cmdline}\n')
            raise

    def build_headless(self):
        from setup.parallel_build import cpu_count
        if iswindows or ishaiku:
            return  # Don't have headless operation on these platforms
        from setup.build_environment import CMAKE, sw
        self.info('\n####### Building headless QPA plugin', '#'*7)
        a = absolutize
        headers = a([
            'calibre/headless/headless_backingstore.h',
            'calibre/headless/headless_integration.h',
        ])
        sources = a([
            'calibre/headless/main.cpp',
            'calibre/headless/headless_backingstore.cpp',
            'calibre/headless/headless_integration.cpp',
        ])
        others = a(['calibre/headless/headless.json'])
        target = self.dest('headless', self.env)
        if not ismacos:
            target = target.replace('headless', 'libheadless')
        if not self.newer(target, headers + sources + others):
            return

        bdir = self.j(self.build_dir, 'headless')
        if os.path.exists(bdir):
            shutil.rmtree(bdir)
        sdir = os.path.join(bdir, 'src')
        shutil.copytree(os.path.dirname(sources[0]), sdir)
        with open(os.path.join(sdir, 'CMakeLists.txt'), 'r+') as f:
            raw = f.read()
            qt = lazy_load('qt')
            if qt['version'] >= (6, 10):
                fp = 'find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Core CorePrivate)'
                ll = 'target_link_libraries(headless PRIVATE Qt6::Gui Qt6::GuiPrivate Qt6::Core Qt6::CorePrivate)'
            else:
                fp = 'find_package(Qt6Gui REQUIRED)'
                ll = 'target_link_libraries(headless PRIVATE Qt::Gui Qt::GuiPrivate Qt::Core Qt::CorePrivate)'
            raw = raw.replace('__FIND_GUI__', fp)
            raw = raw.replace('__LINK_TARGETS__', ll)
            f.seek(0), f.truncate()
            f.write(raw)
        bdir = os.path.join(bdir, 'build')
        cmd = [CMAKE]
        if is_macos_universal_build:
            cmd += ['-DCMAKE_OSX_ARCHITECTURES=x86_64;arm64']
        if sw and os.path.exists(os.path.join(sw, 'qt')):
            cmd += ['-DCMAKE_SYSTEM_PREFIX_PATH=' + os.path.join(sw, 'qt').replace(os.sep, '/')]
        os.makedirs(bdir)
        cwd = os.getcwd()
        os.chdir(bdir)
        try:
            self.check_call(cmd + ['-S', sdir])
            self.check_call([self.env.make] + [f'-j{cpu_count or 1}'])
        finally:
            os.chdir(cwd)
        os.rename(self.j(bdir, 'libheadless.so'), target)

    def create_sip_build_skeleton(self, src_dir, ext):
        from setup.build_environment import pyqt_sip_abi_version
        abi_version = ''
        if pyqt_sip_abi_version():
            abi_version = f'abi-version = "{pyqt_sip_abi_version()}"'
        sipf = ext.sip_files[0]
        needs_exceptions = 'true' if ext.needs_exceptions else 'false'
        with open(os.path.join(src_dir, 'pyproject.toml'), 'w') as f:
            f.write(f'''
[build-system]
requires = ["sip >=5.3", "PyQt-builder >=1"]
build-backend = "sipbuild.api"

[tool.sip]
project-factory = "pyqtbuild:PyQtProject"

[tool.sip.project]
sip-files-dir = "."
{abi_version}

[project]
name = "{ext.name}"

[tool.sip.builder]
qmake-settings = [
    """QMAKE_CC = {self.env.cc}""",
    """QMAKE_CXX = {self.env.cxx}""",
    """QMAKE_LINK = {self.env.linker or self.env.cxx}""",
    """QMAKE_CFLAGS += {shlex.join(self.env.base_cflags)}""",
    """QMAKE_CXXFLAGS += {shlex.join(self.env.base_cxxflags)}""",
    """QMAKE_LFLAGS += {shlex.join(self.env.base_ldflags)}""",
]

[tool.sip.bindings.{ext.name}]
headers = {sorted(ext.headers)}
sources = {sorted(ext.sources)}
exceptions = {needs_exceptions}
include-dirs = {ext.inc_dirs}
qmake-QT = {ext.qt_modules}
sip-file = {os.path.basename(sipf)!r}
''')
        shutil.copy2(sipf, src_dir)

    def get_sip_commands(self, ext):
        from setup.build_environment import QMAKE
        pyqt_dir = self.j(self.build_dir, 'pyqt')
        src_dir = self.j(pyqt_dir, ext.name)
        # TODO: Handle building extensions with multiple SIP files.
        sipf = ext.sip_files[0]
        sbf = self.j(src_dir, self.b(sipf)+'.sbf')
        cmd = None
        cwd = None
        if self.newer(sbf, [sipf] + ext.headers + ext.sources):
            shutil.rmtree(src_dir, ignore_errors=True)
            os.makedirs(src_dir)
            self.create_sip_build_skeleton(src_dir, ext)
            cwd = src_dir
            cmd = [
                sys.executable, '-m', 'sipbuild.tools.build',
                '--verbose', '--no-make', '--qmake', QMAKE
            ]
        return cmd, sbf, cwd

    def build_pyqt_extension(self, ext, sbf):
        self.info(f'\n####### Building {ext.name} extension', '#'*7)
        src_dir = os.path.dirname(sbf)
        cwd = os.getcwd()
        try:
            os.chdir(os.path.join(src_dir, 'build'))
            env = os.environ.copy()
            if is_macos_universal_build:
                env['ARCHS'] = 'x86_64 arm64'
            self.check_call([self.env.make] + ([] if iswindows else [f'-j{os.cpu_count() or 1}']), env=env)
            e = 'pyd' if iswindows else 'so'
            m = glob.glob(f'{ext.name}/{ext.name}.*{e}')
            if not m:
                raise SystemExit(f'No built PyQt extension file in {os.path.join(os.getcwd(), ext.name)}')
            if len(m) != 1:
                raise SystemExit(f'Found extra PyQt extension files: {m}')
            shutil.copy2(m[0], self.dest(ext, self.env))
            with open(sbf, 'w') as f:
                f.write('done')
        finally:
            os.chdir(cwd)

    def clean(self):
        self.output_dir = self.DEFAULT_OUTPUTDIR
        extensions = map(parse_extension, read_extensions())
        env = init_env()
        for ext in extensions:
            dest = self.dest(ext, env)
            b, d = os.path.basename(dest), os.path.dirname(dest)
            b = b.split('.')[0] + '.*'
            for x in glob.glob(os.path.join(d, b)):
                if os.path.exists(x):
                    os.remove(x)
        build_dir = self.DEFAULT_BUILDDIR
        if os.path.exists(build_dir):
            shutil.rmtree(build_dir)
