#!/usr/bin/env python3
# Copyright 2011 The Emscripten Authors.  All rights reserved.
# Emscripten is available under two separate licenses, the MIT license and the
# University of Illinois/NCSA Open Source License.  Both these licenses can be
# found in the LICENSE file.

"""emcc - compiler helper script
=============================

emcc is a drop-in replacement for a compiler like gcc or clang.

See  emcc --help  for details.

emcc can be influenced by a few environment variables:

  EMCC_DEBUG - "1" will log out useful information during compilation, as well as
               save each compiler step as an emcc-* file in the temp dir
               (by default /tmp/emscripten_temp). "2" will save additional emcc-*
               steps, that would normally not be separately produced (so this
               slows down compilation).
"""

import logging
import os
import shlex
import shutil
import sys
import tarfile
from dataclasses import dataclass
from enum import Enum, auto, unique

from tools import (
  building,
  cache,
  cmdline,
  compile,
  config,
  diagnostics,
  shared,
  system_libs,
  utils,
)
from tools.cmdline import CLANG_FLAGS_WITH_ARGS, options
from tools.response_file import substitute_response_files
from tools.settings import COMPILE_TIME_SETTINGS, default_setting, settings, user_settings
from tools.shared import DEBUG, DYLIB_EXTENSIONS, in_temp
from tools.toolchain_profiler import ToolchainProfiler
from tools.utils import exit_with_error, get_file_suffix, read_file, unsuffixed_basename

logger = logging.getLogger('emcc')

# In git checkouts of emscripten `bootstrap.py` exists to run post-checkout
# steps.  In packaged versions (e.g. emsdk) this file does not exist (because
# it is excluded in tools/install.py) and these steps are assumed to have been
# run already.
if os.path.exists(utils.path_from_root('.git')) and os.path.exists(utils.path_from_root('bootstrap.py')):
  import bootstrap
  bootstrap.check()

PREPROCESSED_EXTENSIONS = {'.i', '.ii'}
ASSEMBLY_EXTENSIONS = {'.s'}
HEADER_EXTENSIONS = {'.h', '.hxx', '.hpp', '.hh', '.H', '.HXX', '.HPP', '.HH'}
SOURCE_EXTENSIONS = {
  '.c', '.i', # C
  '.cppm', '.pcm', '.cpp', '.cxx', '.cc', '.c++', '.CPP', '.CXX', '.C', '.CC', '.C++', '.ii', # C++
  '.m', '.mi', '.mm', '.mii', # ObjC/ObjC++
  '.bc', '.ll', # LLVM IR
  '.S', # asm with preprocessor
  os.devnull, # consider the special endingless filenames like /dev/null to be C
} | PREPROCESSED_EXTENSIONS

LINK_ONLY_FLAGS = {
    '--bind', '--closure', '--cpuprofiler', '--embed-file',
    '--emit-symbol-map', '--emrun', '--exclude-file', '--extern-post-js',
    '--extern-pre-js', '--ignore-dynamic-linking', '--js-library',
    '--js-transform', '--oformat', '--output_eol', '--output-eol',
    '--post-js', '--pre-js', '--preload-file', '--profiling-funcs',
    '--proxy-to-worker', '--shell-file', '--source-map-base',
    '--threadprofiler', '--use-preload-plugins',
}


@unique
class Mode(Enum):
  # Used any time we are not linking, including PCH, pre-processing, etc
  COMPILE_ONLY = auto()
  # Only when --post-link is specified
  POST_LINK_ONLY = auto()
  # This is the default mode, in the absence of any flags such as -c, -E, etc
  COMPILE_AND_LINK = auto()


@dataclass
class LinkFlag:
  """Used to represent a linker flag.

  The flag value is stored along with a bool that distinguishes input
  files from non-files.

  A list of these is returned by separate_linker_flags.
  """
  value: str
  is_file: int


class EmccState:
  def __init__(self, args):
    self.mode = Mode.COMPILE_AND_LINK
    # Using tuple here to prevent accidental mutation
    self.orig_args = tuple(args)


def create_reproduce_file(name, args):
  def make_relative(filename):
    filename = os.path.normpath(os.path.abspath(filename))
    filename = os.path.splitdrive(filename)[1]
    filename = filename[1:]
    return filename

  root = unsuffixed_basename(name)
  with tarfile.open(name, 'w') as reproduce_file:
    reproduce_file.add(utils.path_from_root('emscripten-version.txt'), os.path.join(root, 'version.txt'))

    with shared.get_temp_files().get_file(suffix='.tar') as rsp_name:
      with open(rsp_name, 'w') as rsp:
        ignore_next = False
        output_arg = None

        for arg in args:
          ignore = ignore_next
          ignore_next = False
          if arg.startswith('--reproduce='):
            continue

          if len(arg) > 2 and arg.startswith('-o'):
            rsp.write('-o\n')
            arg = arg[3:]
            output_arg = True
            ignore = True

          if output_arg:
            # If -o path contains directories, "emcc @response.txt" will likely
            # fail because the archive we are creating doesn't contain empty
            # directories for the output path (-o doesn't create directories).
            # Strip directories to prevent the issue.
            arg = os.path.basename(arg)
            output_arg = False

          if not arg.startswith('-') and not ignore:
            relpath = make_relative(arg)
            rsp.write(relpath + '\n')
            reproduce_file.add(arg, os.path.join(root, relpath))
          else:
            rsp.write(arg + '\n')

          if ignore:
            continue

          if arg in CLANG_FLAGS_WITH_ARGS:
            ignore_next = True

          if arg == '-o':
            output_arg = True

      reproduce_file.add(rsp_name, os.path.join(root, 'response.txt'))


@ToolchainProfiler.profile()
def main(args):
  if shared.run_via_emxx:
    clang = shared.CLANG_CXX
  else:
    clang = shared.CLANG_CC

  # Special case the handling of `-v` because it has a special/different meaning
  # when used with no other arguments.  In particular, we must handle this early
  # on, before we inject EMCC_CFLAGS.  This is because tools like cmake and
  # autoconf will run `emcc -v` to determine the compiler version and we don't
  # want that to break for users of EMCC_CFLAGS.
  if len(args) == 2 and args[1] == '-v':
    # autoconf likes to see 'GNU' in the output to enable shared object support
    print(cmdline.version_string(), file=sys.stderr)
    return shared.check_call([clang, '-v'] + compile.get_target_flags(), check=False).returncode

  # Additional compiler flags that we treat as if they were passed to us on the
  # commandline
  if EMCC_CFLAGS := os.environ.get('EMCC_CFLAGS'):
    args += shlex.split(EMCC_CFLAGS)

  if DEBUG:
    logger.warning(f'invocation: {shlex.join(args)} (in {os.getcwd()})')

  # Strip args[0] (program name)
  args = args[1:]

  # Handle some global flags

  # read response files very early on
  try:
    args = substitute_response_files(args)
  except OSError as e:
    exit_with_error(e)

  if '--help' in args:
    # Documentation for emcc and its options must be updated in:
    #    site/source/docs/tools_reference/emcc.rst
    # This then gets built (via: `make -C site text`) to:
    #    site/build/text/docs/tools_reference/emcc.txt
    # This then needs to be copied to its final home in docs/emcc.txt from where
    # we read it here.  We have CI rules that ensure its always up-to-date.
    print(read_file(utils.path_from_root('docs/emcc.txt')))

    print('''
------------------------------------------------------------------

emcc: supported targets: llvm bitcode, WebAssembly, NOT elf
(autoconf likes to see elf above to enable shared object support)
''')
    return 0

  ## Process argument and setup the compiler
  state = EmccState(args)
  newargs = cmdline.parse_arguments(state.orig_args)

  if not shared.SKIP_SUBPROCS:
    shared.check_sanity()

  # Begin early-exit flag handling.

  if '--version' in args:
    print(cmdline.version_string())
    print('''\
Copyright (C) 2026 the Emscripten authors (see AUTHORS.txt)
This is free and open source software under the MIT license.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
''')
    return 0

  if '-dumpversion' in args: # gcc's doc states "Print the compiler version [...] and don't do anything else."
    print(utils.EMSCRIPTEN_VERSION)
    return 0

  if '-dumpmachine' in args or '-print-target-triple' in args or '--print-target-triple' in args:
    print(shared.get_llvm_target())
    return 0

  if '-print-search-dirs' in args or '--print-search-dirs' in args:
    print(f'programs: ={config.LLVM_ROOT}')
    print(f'libraries: ={cache.get_lib_dir(absolute=True)}')
    return 0

  if '-print-libgcc-file-name' in args or '--print-libgcc-file-name' in args:
    settings.limit_settings(None)
    compiler_rt = system_libs.Library.get_usable_variations()['libcompiler_rt']
    print(compiler_rt.get_path(absolute=True))
    return 0

  print_file_name = [a for a in args if a.startswith(('-print-file-name=', '--print-file-name='))]
  if print_file_name:
    libname = print_file_name[-1].split('=')[1]
    system_libpath = cache.get_lib_dir(absolute=True)
    fullpath = os.path.join(system_libpath, libname)
    if os.path.isfile(fullpath):
      print(fullpath)
    else:
      print(libname)
    return 0

  # End early-exit flag handling

  if 'EMMAKEN_NO_SDK' in os.environ:
    exit_with_error('EMMAKEN_NO_SDK is no longer supported.  The standard -nostdlib and -nostdinc flags should be used instead')

  if 'EMMAKEN_COMPILER' in os.environ:
    exit_with_error('`EMMAKEN_COMPILER` is no longer supported.\n' +
                    'Please use the `LLVM_ROOT` and/or `COMPILER_WRAPPER` config settings instead')

  if 'EMMAKEN_CFLAGS' in os.environ:
    exit_with_error('`EMMAKEN_CFLAGS` is no longer supported, please use `EMCC_CFLAGS` instead')

  if 'EMCC_REPRODUCE' in os.environ:
    options.reproduce = os.environ['EMCC_REPRODUCE']

  # For internal consistency, ensure we don't attempt to read or write any link time
  # settings until we reach the linking phase.
  settings.limit_settings(COMPILE_TIME_SETTINGS)

  phase_setup(state)

  if '-print-resource-dir' in args or any(a.startswith('--print-prog-name') for a in args):
    shared.exec_process([clang] + compile.get_cflags(tuple(args)) + args)
    assert False, 'exec_process should not return'

  if '--cflags' in args:
    # Just print the flags we pass to clang and exit.  We need to do this after
    # phase_setup because the setup sets things like SUPPORT_LONGJMP.
    cflags = compile.get_cflags(x for x in args if x != '--cflags')
    print(shlex.join(cflags))
    return 0

  if options.reproduce:
    create_reproduce_file(options.reproduce, args)

  if state.mode == Mode.POST_LINK_ONLY:
    if len(options.input_files) != 1:
      exit_with_error('--post-link requires a single input file')
    linker_args = separate_linker_flags(newargs)[1]
    linker_args = [f.value for f in linker_args]
    # Delay import of link.py to avoid processing this file when only compiling
    from tools import link  # noqa: PLC0415
    link.run_post_link(options.input_files[0], options, linker_args)
    return 0

  # Compile source code to object files
  # When only compiling this function never returns.
  linker_args = phase_compile_inputs(options, state, newargs)

  if state.mode == Mode.COMPILE_AND_LINK:
    # Delay import of link.py to avoid processing this file when only compiling
    from tools import link
    return link.run(options, linker_args)
  else:
    logger.debug('stopping after compile phase')
    return 0


def separate_linker_flags(newargs):
  """Process argument list separating out compiler args and linker args.

  - Linker flags include input files and are returned a list of LinkFlag objects.
  - Compiler flags are those to be passed to `clang -c`.
  """

  compiler_args = []
  linker_args = []

  def add_link_arg(flag, is_file=False):
    linker_args.append(LinkFlag(flag, is_file))

  skip = False
  for i in range(len(newargs)):
    if skip:
      skip = False
      continue

    arg = newargs[i]
    if arg in CLANG_FLAGS_WITH_ARGS:
      skip = True

    def get_next_arg():
      if len(newargs) <= i + 1:
        exit_with_error(f"option '{arg}' requires an argument")
      return newargs[i + 1]

    if not arg.startswith('-') or arg == '-':
      if not os.path.exists(arg) and arg != '-':
        exit_with_error('%s: No such file or directory ("%s" was expected to be an input file, based on the commandline arguments provided)', arg, arg)
      add_link_arg(arg, True)
    elif arg == '-z':
      add_link_arg(arg)
      add_link_arg(get_next_arg())
    elif arg.startswith('-Wl,'):
      for flag in arg.split(',')[1:]:
        add_link_arg(flag)
    elif arg == '-Xlinker':
      add_link_arg(get_next_arg())
    elif arg == '-s' or arg.startswith(('-l', '-L', '--js-library=', '-z', '-u')):
      add_link_arg(arg)
    elif not arg.startswith('-o') and arg not in ('-nostdlib', '-nostartfiles', '-nolibc', '-nodefaultlibs', '-s'):
      # All other flags are for the compiler
      compiler_args.append(arg)
      if skip:
        compiler_args.append(get_next_arg())

  return compiler_args, linker_args


@ToolchainProfiler.profile_block('setup')
def phase_setup(state):
  """Second phase: configure and setup the compiler based on the specified settings and arguments.
  """

  has_header_inputs = any(get_file_suffix(f) in HEADER_EXTENSIONS for f in options.input_files)

  if options.post_link:
    state.mode = Mode.POST_LINK_ONLY
  elif has_header_inputs or options.dash_c or options.dash_S or options.syntax_only or options.dash_E or options.dash_M:
    state.mode = Mode.COMPILE_ONLY

  if state.mode == Mode.COMPILE_ONLY:
    for key in user_settings:
      if key not in COMPILE_TIME_SETTINGS:
        diagnostics.warning(
            'unused-command-line-argument',
            "linker setting ignored during compilation: '%s'" % key)
    for arg in state.orig_args:
      if arg in LINK_ONLY_FLAGS:
        diagnostics.warning(
            'unused-command-line-argument',
            "linker flag ignored during compilation: '%s'" % arg)

  if settings.SIDE_MODULE:
    settings.RELOCATABLE = 1

  if 'USE_PTHREADS' in user_settings:
    settings.PTHREADS = settings.USE_PTHREADS

  # Pthreads and Wasm Workers require targeting shared Wasm memory (SAB).
  if settings.PTHREADS or settings.WASM_WORKERS:
    settings.SHARED_MEMORY = 1

  if 'DISABLE_EXCEPTION_CATCHING' in user_settings and 'EXCEPTION_CATCHING_ALLOWED' in user_settings:
    # If we get here then the user specified both DISABLE_EXCEPTION_CATCHING and EXCEPTION_CATCHING_ALLOWED
    # on the command line.  This is no longer valid so report either an error or a warning (for
    # backwards compat with the old `DISABLE_EXCEPTION_CATCHING=2`
    if user_settings['DISABLE_EXCEPTION_CATCHING'] in ('0', '2'):
      diagnostics.warning('deprecated', 'DISABLE_EXCEPTION_CATCHING=X is no longer needed when specifying EXCEPTION_CATCHING_ALLOWED')
    else:
      exit_with_error('DISABLE_EXCEPTION_CATCHING and EXCEPTION_CATCHING_ALLOWED are mutually exclusive')

  if settings.EXCEPTION_CATCHING_ALLOWED:
    settings.DISABLE_EXCEPTION_CATCHING = 0

  if settings.WASM_EXCEPTIONS:
    if user_settings.get('DISABLE_EXCEPTION_CATCHING') == '0':
      exit_with_error('DISABLE_EXCEPTION_CATCHING=0 is not compatible with -fwasm-exceptions')
    if user_settings.get('DISABLE_EXCEPTION_THROWING') == '0':
      exit_with_error('DISABLE_EXCEPTION_THROWING=0 is not compatible with -fwasm-exceptions')
    # -fwasm-exceptions takes care of enabling them, so users aren't supposed to
    # pass them explicitly, regardless of their values
    if 'DISABLE_EXCEPTION_CATCHING' in user_settings or 'DISABLE_EXCEPTION_THROWING' in user_settings:
      diagnostics.warning('emcc', 'you no longer need to pass DISABLE_EXCEPTION_CATCHING or DISABLE_EXCEPTION_THROWING when using Wasm exceptions')
    settings.DISABLE_EXCEPTION_CATCHING = 1
    settings.DISABLE_EXCEPTION_THROWING = 1

    if user_settings.get('ASYNCIFY') == '1':
      diagnostics.warning('emcc', 'ASYNCIFY=1 is not compatible with -fwasm-exceptions. Parts of the program that mix ASYNCIFY and exceptions will not compile.')

    if user_settings.get('SUPPORT_LONGJMP') == 'emscripten':
      exit_with_error('SUPPORT_LONGJMP=emscripten is not compatible with -fwasm-exceptions')

  if settings.DISABLE_EXCEPTION_THROWING and not settings.DISABLE_EXCEPTION_CATCHING:
    exit_with_error("DISABLE_EXCEPTION_THROWING was set (probably from -fno-exceptions) but is not compatible with enabling exception catching (DISABLE_EXCEPTION_CATCHING=0). If you don't want exceptions, set DISABLE_EXCEPTION_CATCHING to 1; if you do want exceptions, don't link with -fno-exceptions")

  if options.target.startswith('wasm64'):
    default_setting('MEMORY64', 1)

  if settings.MEMORY64 and options.target.startswith('wasm32'):
    exit_with_error('wasm32 target is not compatible with -sMEMORY64')

  # Wasm SjLj cannot be used with Emscripten EH
  if settings.SUPPORT_LONGJMP == 'wasm':
    # DISABLE_EXCEPTION_THROWING is 0 by default for Emscripten EH throwing, but
    # Wasm SjLj cannot be used with Emscripten EH. We error out if
    # DISABLE_EXCEPTION_THROWING=0 is explicitly requested by the user;
    # otherwise we disable it here.
    if user_settings.get('DISABLE_EXCEPTION_THROWING') == '0':
      exit_with_error('SUPPORT_LONGJMP=wasm cannot be used with DISABLE_EXCEPTION_THROWING=0')
    # We error out for DISABLE_EXCEPTION_CATCHING=0, because it is 1 by default
    # and this can be 0 only if the user specifies so.
    if user_settings.get('DISABLE_EXCEPTION_CATCHING') == '0':
      exit_with_error('SUPPORT_LONGJMP=wasm cannot be used with DISABLE_EXCEPTION_CATCHING=0')
    default_setting('DISABLE_EXCEPTION_THROWING', 1)

  # SUPPORT_LONGJMP=1 means the default SjLj handling mechanism, which is 'wasm'
  # if Wasm EH is used and 'emscripten' otherwise.
  if settings.SUPPORT_LONGJMP == 1:
    if settings.WASM_EXCEPTIONS:
      settings.SUPPORT_LONGJMP = 'wasm'
    else:
      settings.SUPPORT_LONGJMP = 'emscripten'


@ToolchainProfiler.profile_block('compile inputs')
def phase_compile_inputs(options, state, newargs):
  if shared.run_via_emxx:
    compiler = [shared.CLANG_CXX]
  else:
    compiler = [shared.CLANG_CC]

  if config.COMPILER_WRAPPER:
    logger.debug('using compiler wrapper: %s', config.COMPILER_WRAPPER)
    compiler.insert(0, config.COMPILER_WRAPPER)

  system_libs.ensure_sysroot()

  def get_clang_command():
    return compiler + compile.get_cflags(state.orig_args)

  def get_clang_command_preprocessed():
    return compiler + compile.get_clang_flags(state.orig_args)

  def get_clang_command_asm():
    return compiler + compile.get_target_flags()

  if state.mode == Mode.COMPILE_ONLY:
    if options.output_file and get_file_suffix(options.output_file) == '.bc' and not settings.LTO and '-emit-llvm' not in state.orig_args:
      diagnostics.warning('emcc', '.bc output file suffix used without -flto or -emit-llvm.  Consider using .o extension since emcc will output an object file, not a bitcode file')
    if all(get_file_suffix(i) in ASSEMBLY_EXTENSIONS for i in options.input_files):
      cmd = get_clang_command_asm() + newargs
    else:
      cmd = get_clang_command() + newargs
    shared.exec_process(cmd)
    assert False, 'exec_process should not return'

  # In COMPILE_AND_LINK we need to compile source files too, but we also need to
  # filter out the link flags
  assert state.mode == Mode.COMPILE_AND_LINK
  assert not options.dash_c
  compile_args, linker_args = separate_linker_flags(newargs)

  # Map of file basenames to how many times we've seen them.  We use this to generate
  # unique `_NN` suffix for object files in cases when we are compiling multiple sources that
  # have the same basename.  e.g. `foo/utils.c` and `bar/utils.c` on the same command line.
  seen_names = {}

  def uniquename(name):
    if name not in seen_names:
      # No suffix needed the first time we see given name.
      seen_names[name] = 1
      return name

    unique_suffix = '_%d' % seen_names[name]
    seen_names[name] += 1
    base, ext = os.path.splitext(name)
    return base + unique_suffix + ext

  def get_object_filename(input_file):
    objfile = unsuffixed_basename(input_file) + '.o'
    return in_temp(uniquename(objfile))

  def compile_source_file(input_file):
    logger.debug(f'compiling source file: {input_file}')
    output_file = get_object_filename(input_file)
    ext = get_file_suffix(input_file)
    if ext in ASSEMBLY_EXTENSIONS:
      cmd = get_clang_command_asm()
    elif ext in PREPROCESSED_EXTENSIONS:
      cmd = get_clang_command_preprocessed()
    else:
      cmd = get_clang_command()
      if ext == '.pcm':
        cmd = [c for c in cmd if not c.startswith('-fprebuilt-module-path=')]
    cmd += compile_args + ['-c', input_file, '-o', output_file]
    if options.requested_debug == '-gsplit-dwarf':
      # When running in COMPILE_AND_LINK mode we compile objects to a temporary location
      # but we want the `.dwo` file to be generated in the current working directory,
      # like it is under clang.  We could avoid this hack if we use the clang driver
      # to generate the temporary files, but that would also involve using the clang
      # driver to perform linking which would be a big change.
      cmd += ['-Xclang', '-split-dwarf-file', '-Xclang', unsuffixed_basename(input_file) + '.dwo']
      cmd += ['-Xclang', '-split-dwarf-output', '-Xclang', unsuffixed_basename(input_file) + '.dwo']
    shared.check_call(cmd)
    if not shared.SKIP_SUBPROCS:
      assert os.path.exists(output_file)
      if options.save_temps:
        shutil.copyfile(output_file, utils.unsuffixed_basename(input_file) + '.o')
    return output_file

  # Compile input files individually to temporary locations.
  for arg in linker_args:
    if not arg.is_file:
      continue
    input_file = arg.value
    file_suffix = get_file_suffix(input_file)
    if file_suffix in SOURCE_EXTENSIONS | ASSEMBLY_EXTENSIONS or (options.dash_c and file_suffix == '.bc'):
      arg.value = compile_source_file(input_file)
    elif file_suffix in DYLIB_EXTENSIONS:
      logger.debug(f'using shared library: {input_file}')
    elif building.is_ar(input_file):
      logger.debug(f'using static library: {input_file}')
    elif options.input_language:
      arg.value = compile_source_file(input_file)
    elif input_file == '-':
      exit_with_error('-E or -x required when input is from standard input')
    else:
      # Default to assuming the inputs are object files and pass them to the linker
      pass

  return [f.value for f in linker_args]


if __name__ == '__main__':
  try:
    sys.exit(main(sys.argv))
  except KeyboardInterrupt:
    logger.debug('KeyboardInterrupt')
    sys.exit(1)
