# Copyright 2014 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.

import glob
import hashlib
import importlib.util
import logging
import os
import shutil
import subprocess
import sys
from inspect import signature
from pathlib import Path
from urllib.request import urlopen

from tools import cache, config, shared, system_libs, utils
from tools.settings import settings
from tools.toolchain_profiler import ToolchainProfiler

ports: list[dict] = []

ports_by_name: dict[str, object] = {}

ports_needed: set[str] = set()

# Variant builds that we want to support for certain ports
# {variant_name: (port_name, extra_settings)}
port_variants: dict[str, tuple] = {}

ports_dir = os.path.dirname(os.path.abspath(__file__))

logger = logging.getLogger('ports')


def get_port_by_name(name):
  port = ports_by_name[name]
  if port.is_external:
    load_external_port(port)
    return ports_by_name[name]
  else:
    return port


def init_port(name, port):
  ports.append(port)
  port.is_contrib = name.startswith('contrib.')
  port.is_external = hasattr(port, 'EXTERNAL_PORT')
  port.name = name
  ports_by_name[port.name] = port
  if port.is_external:
    init_external_port(name, port)
  else:
    init_local_port(name, port)


def init_local_port(name, port):
  if not hasattr(port, 'needed'):
    port.needed = lambda s: name in ports_needed
  else:
    needed = port.needed
    port.needed = lambda s: needed(s) or name in ports_needed
  if not hasattr(port, 'process_dependencies'):
    port.process_dependencies = lambda x: 0
  if not hasattr(port, 'linker_setup'):
    port.linker_setup = lambda x, y: 0
  if not hasattr(port, 'deps'):
    port.deps = []
  if not hasattr(port, 'process_args'):
    port.process_args = lambda x: []
  if not hasattr(port, 'variants'):
    # port variants (default: no variants)
    port.variants = {}
  if not hasattr(port, 'show'):
    port.show = lambda: f'{port.name} (--use-port={port.name}; {port.LICENSE})'

  for variant, extra_settings in port.variants.items():
    if variant in port_variants:
      utils.exit_with_error('duplicate port variant: `%s`' % variant)
    port_variants[variant] = (port.name, extra_settings)

  validate_port(port)


def load_port_module(module_name, port_file):
  spec = importlib.util.spec_from_file_location(module_name, port_file)
  port = importlib.util.module_from_spec(spec)
  spec.loader.exec_module(port)
  return port


def load_external_port(external_port):
  name = external_port.name
  up_to_date = Ports.fetch_port_artifact(name, external_port.EXTERNAL_PORT, external_port.SHA512)
  port_file = os.path.join(Ports.get_dir(), name, external_port.PORT_FILE)
  local_port = load_port_module(f'tools.ports.external.{name}', port_file)
  ports.remove(external_port)
  for a in ['URL', 'DESCRIPTION', 'LICENSE']:
    if not hasattr(local_port, a):
      setattr(local_port, a, getattr(external_port, a))
  init_port(name, local_port)
  if not up_to_date:
    Ports.clear_project_build(name)


def init_external_port(name, port):
  expected_attrs = ['SHA512', 'PORT_FILE', 'URL', 'DESCRIPTION', 'LICENSE']
  for a in expected_attrs:
    assert hasattr(port, a), 'port %s is missing %s' % (port, a)
  port.needed = lambda s: name in ports_needed
  port.show = lambda: f'{port.name} (--use-port={port.name}; {port.LICENSE})'


def load_port(path, name=None):
  if not name:
    name = utils.unsuffixed_basename(path)
  if name in ports_by_name:
    utils.exit_with_error(f'port path [`{path}`] is invalid: duplicate port name `{name}`')
  port = load_port_module(f'tools.ports.{name}', path)
  init_port(name, port)
  return name


def validate_port(port):
  expected_attrs = ['get', 'clear', 'show']
  if port.is_contrib:
    expected_attrs += ['URL', 'DESCRIPTION', 'LICENSE']
  if hasattr(port, 'handle_options'):
    expected_attrs += ['OPTIONS']
  for a in expected_attrs:
    assert hasattr(port, a), 'port %s is missing %s' % (port, a)


@ToolchainProfiler.profile()
def read_ports():
  for filename in os.listdir(ports_dir):
    if not filename.endswith('.py') or filename == '__init__.py':
      continue
    load_port(os.path.join(ports_dir, filename))

  contrib_dir = os.path.join(ports_dir, 'contrib')
  for filename in os.listdir(contrib_dir):
    if not filename.endswith('.py') or filename == '__init__.py':
      continue
    name = 'contrib.' + utils.unsuffixed(filename)
    load_port(os.path.join(contrib_dir, filename), name)


def get_all_files_under(dirname):
  for path, _, files in os.walk(dirname):
    for name in files:
      yield os.path.join(path, name)


def dir_is_newer(dir_a, dir_b):
  assert os.path.exists(dir_a)
  assert os.path.exists(dir_b)
  files_a = ((x, os.path.getmtime(x)) for x in get_all_files_under(dir_a))
  files_b = ((x, os.path.getmtime(x)) for x in get_all_files_under(dir_b))
  newest_a = max(files_a, key=lambda f: f[1])
  newest_b = max(files_b, key=lambda f: f[1])
  logger.debug('newest_a: %s %s', *newest_a)
  logger.debug('newest_b: %s %s', *newest_b)
  return newest_a[1] > newest_b[1]


def maybe_copy(src, dest):
  """Copy a file, but only if the destination is out-of-date.

  Just like shutil.copyfile, but will do nothing if the destination already
  exists and has the same contents as the source.

  In the case where a library is built in multiple different configurations,
  we want to avoids racing between processes that are reading headers (without
  holding the cache lock) (e.g. normal compile steps) and a process that is
  building/installing a new flavor of a given library.  In this case the
  headers will be "re-installed" but we skip the actual filesystem mods
  to avoid racing with other processes that might be reading these files.
  """
  if os.path.exists(dest) and utils.read_binary(src) == utils.read_binary(dest):
    return
  shutil.copyfile(src, dest)


class Ports:
  """emscripten-ports library management (https://github.com/emscripten-ports)."""

  @staticmethod
  def get_include_dir(*parts):
    dirname = cache.get_include_dir(*parts)
    utils.safe_ensure_dirs(dirname)
    return dirname

  @staticmethod
  def install_header_dir(src_dir, target=None):
    """Like install_headers but recursively copied all files in a directory."""
    if not target:
      target = os.path.basename(src_dir)
    dest = Ports.get_include_dir(target)
    logger.debug(f'installing headers: {dest}')
    shutil.copytree(src_dir, dest, dirs_exist_ok=True, copy_function=maybe_copy)

  @staticmethod
  def install_file(filename, target):
    sysroot = cache.get_sysroot_dir()
    target_dir = os.path.join(sysroot, os.path.dirname(target))
    os.makedirs(target_dir, exist_ok=True)
    maybe_copy(filename, os.path.join(sysroot, target))

  @staticmethod
  def install_headers(src_dir, pattern='*.h', target=None):
    logger.debug('install_headers')
    dest = Ports.get_include_dir()
    assert os.path.exists(dest)
    if target:
      dest = os.path.join(dest, target)
      utils.safe_ensure_dirs(dest)
    matches = glob.glob(os.path.join(src_dir, pattern))
    assert matches, f'no headers found to install in {src_dir}'
    for f in matches:
      logger.debug('installing: ' + os.path.join(dest, os.path.basename(f)))
      maybe_copy(f, os.path.join(dest, os.path.basename(f)))

  @staticmethod
  def build_port(src_dir, output_path, port_name, includes=[], flags=[], cxxflags=[], exclude_files=[], exclude_dirs=[], srcs=[]):  # ruff: ignore[mutable-argument-default]
    mangled_name = str(Path(output_path).relative_to(Path(cache.get_sysroot(True)) / 'lib'))
    mangled_name = mangled_name.replace(os.sep, '_').replace('.a', '').replace('-emscripten', '')
    build_dir = os.path.join(Ports.get_build_dir(), port_name, mangled_name)
    logger.debug(f'build_port: {port_name} {output_path} in {build_dir}')
    if srcs:
      srcs = [os.path.join(src_dir, s) for s in srcs]
    else:
      srcs = []
      for root, dirs, files in os.walk(src_dir):
        for ex in exclude_dirs:
          if ex in dirs:
            dirs.remove(ex)
        for f in files:
          ext = utils.suffix(f)
          if ext in {'.c', '.cpp'} and not any((excluded in f) for excluded in exclude_files):
            srcs.append(os.path.join(root, f))

    cflags = [*system_libs.get_base_cflags(build_dir), '-O2', '-I' + src_dir, *flags]
    for include in includes:
      cflags.append('-I' + include)

    if system_libs.USE_NINJA:
      os.makedirs(build_dir, exist_ok=True)
      ninja_file = os.path.join(build_dir, 'build.ninja')
      system_libs.ensure_sysroot()
      system_libs.create_ninja_file(srcs, ninja_file, output_path, cflags=cflags)
      if not os.getenv('EMBUILDER_PORT_BUILD_DEFERRED'):
        system_libs.run_ninja(build_dir)
    else:
      commands = []
      objects = []
      for src in srcs:
        relpath = os.path.relpath(src, src_dir)
        obj = os.path.join(build_dir, relpath) + '.o'
        dirname = os.path.dirname(obj)
        os.makedirs(dirname, exist_ok=True)
        cmd = [shared.EMCC, '-c', src, '-o', obj, *cflags]
        if utils.suffix(src) in {'.cc', '.cxx', '.cpp'}:
          cmd[0] = shared.EMXX
          cmd += cxxflags
        commands.append(cmd)
        objects.append(obj)

      system_libs.run_build_commands(commands, num_inputs=len(srcs))
      system_libs.create_lib(output_path, objects)

    return output_path

  @staticmethod
  def get_dir(*parts):
    dirname = os.path.join(config.PORTS, *parts)
    utils.safe_ensure_dirs(dirname)
    return dirname

  @staticmethod
  def erase():
    dirname = Ports.get_dir()
    utils.delete_dir(dirname)

  @staticmethod
  def get_build_dir():
    return system_libs.get_build_dir()

  name_cache: set[str] = set()

  @staticmethod
  def fetch_port_artifact(name, url, sha512hash=None):
    """Fetch the port and return True when the port is up to date, False otherwise."""
    # To compute the sha512 hash, run `curl URL | sha512sum`.
    fullname = Ports.get_dir(name)

    if name not in Ports.name_cache: # only mention each port once in log
      logger.debug(f'including port: {name}')
      logger.debug(f'    (at {fullname})')
      Ports.name_cache.add(name)

    # EMCC_LOCAL_PORTS: A hacky way to use a local directory for a port. This
    #                   is not tested but can be useful for debugging
    #                   changes to a port.
    #
    # if EMCC_LOCAL_PORTS is set, we use a local directory as our ports. This is useful
    # for testing. This env var should be in format
    #     name=dir,name=dir
    # e.g.
    #     sdl2=/home/username/dev/ports/SDL2
    # so you could run
    #     EMCC_LOCAL_PORTS="sdl2=/home/alon/Dev/ports/SDL2" ./test/runner.py browser.test_sdl2_mouse
    # this will simply copy that directory into the ports directory for sdl2, and use that. It also
    # clears the build, so that it is rebuilt from that source.
    local_ports = os.environ.get('EMCC_LOCAL_PORTS')
    if local_ports:
      logger.warning('using local ports: %s' % local_ports)
      local_ports = [pair.split('=', 1) for pair in local_ports.split(',')]
      for local_name, path in local_ports:
        if name == local_name:
          port = ports_by_name.get(name)
          if not port:
            utils.exit_with_error('%s is not a known port' % name)
          if not hasattr(port, 'SUBDIR'):
            utils.exit_with_error(f'port {name} lacks .SUBDIR attribute, which we need in order to override it locally, please update it')
          subdir = port.SUBDIR
          target = os.path.join(fullname, subdir)

          uptodate_message = f'not grabbing local port: {name} from {path} to {fullname} (subdir: {subdir}) as the destination {target} is newer (run emcc --clear-ports if that is incorrect)'
          # before acquiring the lock we have an early out if the port already exists
          if os.path.exists(target) and dir_is_newer(path, target):
            logger.warning(uptodate_message)
            return True
          with cache.lock('unpack local port'):
            # Another early out in case another process unpackage the library while we were
            # waiting for the lock
            if os.path.exists(target) and not dir_is_newer(path, target):
              logger.warning(uptodate_message)
              return True
            logger.warning(f'grabbing local port: {name} from {path} to {fullname} (subdir: {subdir})')
            utils.delete_dir(fullname)
            shutil.copytree(path, target)
            return False

    url_filename = url.rsplit('/')[-1]
    ext = url_filename.split('.', 1)[1]
    fullpath = fullname + '.' + ext

    def retrieve():
      # retrieve from remote server
      logger.info(f'retrieving port: {name} from {url}')

      if utils.MACOS:
        # Use `curl` over `urllib` on macOS to avoid issues with
        # certificate verification.
        # https://stackoverflow.com/questions/40684543/how-to-make-python-use-ca-certificates-from-mac-os-truststore
        # Unlike on Windows or Linux, curl is guaranteed to always be
        # available on macOS.
        data = subprocess.check_output(['curl', '-sSL', url])
      else:
        f = urlopen(url)
        data = f.read()

      if sha512hash:
        actual_hash = hashlib.sha512(data).hexdigest()
        if actual_hash != sha512hash:
          utils.exit_with_error(f'Unexpected hash: {actual_hash}\n'
                                'If you are updating the port, please update the hash.')
      utils.write_binary(fullpath, data)

    marker = os.path.join(fullname, '.emscripten_url')

    def unpack():
      logger.info(f'unpacking port: {name}')
      unpack_dir = fullname + '.tmp'
      # We unpack to a temporary directory and then atomically rename it to the
      # final destination. This ensures that the destination directory either
      # does not exist or is 100% complete, avoiding races where other processes
      # might see a partially unpacked directory (lacking the marker) and
      # incorrectly assume it is invalid or needs to be cleared.
      utils.delete_dir(unpack_dir)
      utils.safe_ensure_dirs(unpack_dir)

      shutil.unpack_archive(filename=fullpath, extract_dir=unpack_dir)
      tmp_marker = os.path.join(unpack_dir, '.emscripten_url')
      utils.write_file(tmp_marker, url + '\n')

      # Atomically replace the target directory
      utils.delete_dir(fullname)
      os.replace(unpack_dir, fullname)

    def up_to_date():
      return os.path.exists(marker) and utils.read_file(marker).strip() == url

    # before acquiring the lock we have an early out if the port already exists
    if up_to_date():
      return True

    # main logic. do this under a cache lock, since we don't want multiple jobs to
    # retrieve the same port at once
    cache.ensure() # TODO: find a better place for this (necessary at the moment)
    with cache.lock('unpack port'):
      if os.path.exists(fullpath):
        # Another early out in case another process unpackage the library while we were
        # waiting for the lock
        if up_to_date():
          return True
        # file exists but tag is bad
        logger.warning('local copy of port is not correct, retrieving from remote server')
        utils.delete_dir(fullname)
        utils.delete_file(fullpath)

      retrieve()
      unpack()

      return False

  @staticmethod
  def fetch_project(name, url, sha512hash=None):
    if not Ports.fetch_port_artifact(name, url, sha512hash):
      # we unpacked a new version, clear the build in the cache
      Ports.clear_project_build(name)

  @staticmethod
  def clear_project_build(name):
    port = get_port_by_name(name)
    port.clear(Ports, settings, shared)
    build_dir = os.path.join(Ports.get_build_dir(), name)
    logger.debug(f'clearing port build: {name} {build_dir}')
    utils.delete_dir(build_dir)
    return build_dir

  @staticmethod
  def write_file(filename, contents):
    if os.path.exists(filename) and utils.read_file(filename) == contents:
      return
    utils.write_file(filename, contents)

  @staticmethod
  def make_pkg_config(name, version, flags):
    pkgconfig_dir = cache.get_sysroot_dir('lib/pkgconfig')
    filename = os.path.join(pkgconfig_dir, name + '.pc')
    Ports.write_file(filename, f'''
Name: {name}
Description: {name} port from emscripten
Version: {version}
Libs: {flags}
Cflags: {flags}
''')


class OrderedSet:
  """Partial implementation of OrderedSet.  Just enough for what we need here."""

  def __init__(self, items):
    self.dict = {}
    for i in items:
      self.dict[i] = True

  def __repr__(self):
    return f"OrderedSet({list(self.dict.keys())})"

  def __len__(self):
    return len(self.dict.keys())

  def copy(self):
    return OrderedSet(self.dict.keys())

  def __iter__(self):
    return iter(self.dict.keys())

  def pop(self, index=-1):
    key = list(self.dict.keys())[index]
    self.dict.pop(key)
    return key

  def add(self, item):
    self.dict[item] = True

  def remove(self, item):
    del self.dict[item]


def dependency_order(port_list):
  # Perform topological sort of ports according to the dependency DAG
  port_map = {p.name: p for p in port_list}

  # Perform depth first search of dependency graph adding nodes to
  # the stack only after all children have been explored.
  stack = []
  unsorted = OrderedSet(port_list)

  def dfs(node):
    for dep in node.deps:
      dep, _ = split_port_options(dep)
      child = port_map[dep]
      if child in unsorted:
        unsorted.remove(child)
        dfs(child)
    stack.append(node)

  while unsorted:
    dfs(unsorted.pop())

  return stack


def resolve_dependencies(port_set, settings, cflags_only=False):
  def add_deps(node):
    sig = signature(node.process_dependencies)
    if len(sig.parameters) == 2:
      # The optional second parameter here is useful for ports that want
      # to mutate linker-only settings.  Modifying these settings during the
      # compile phase (or in a compile-only) command generates errors.
      node.process_dependencies(settings, cflags_only)
    else:
      node.process_dependencies(settings)
    for d in node.deps:
      d, _ = split_port_options(d)
      if d not in ports_by_name:
        utils.exit_with_error(f'unknown dependency `{d}` for port `{node.name}`')
      dep = get_port_by_name(d)
      if dep not in port_set:
        port_set.add(dep)
        add_deps(dep)

  for port in port_set.copy():
    add_deps(port)


def handle_use_port_error(arg, message):
  utils.exit_with_error(f'error with `--use-port={arg}` | {message}')


def show_port_help_and_exit(port):
  print(port.show())
  if hasattr(port, 'DESCRIPTION'):
    print(port.DESCRIPTION)
  if hasattr(port, 'OPTIONS'):
    print("Options:")
    for option, desc in port.OPTIONS.items():
      print(f'* {option}: {desc}')
  else:
    print("No options.")
  if hasattr(port, 'URL'):
    print(f'More info: {port.URL}')
  sys.exit(0)


# extract dict and delegate to port.handle_options for handling (format is 'option1=value1:option2=value2')
def handle_port_options(name, options, error_handler):
  port = get_port_by_name(name)
  if options == 'help':
    show_port_help_and_exit(port)
  if not hasattr(port, 'handle_options'):
    error_handler(f'no options available for port `{name}`')
  else:
    options_dict = {}
    for name_value in options.replace('::', '\0').split(':'):
      name_value = name_value.replace('\0', ':')
      nv = name_value.split('=', 1)
      if len(nv) != 2:
        error_handler(f'`{name_value}` is missing a value')
      if nv[0] not in port.OPTIONS:
        error_handler(f'`{nv[0]}` is not supported; available options are {port.OPTIONS}')
      if nv[0] in options_dict:
        error_handler(f'duplicate option `{nv[0]}`')
      options_dict[nv[0]] = nv[1]
    port.handle_options(options_dict, error_handler)


# handle port dependencies (ex: deps=['sdl2_image:formats=jpg'])
def handle_port_deps(name, error_handler):
  port = get_port_by_name(name)
  for dep in port.deps:
    dep_name, dep_options = split_port_options(dep)
    if dep_name not in ports_by_name:
      error_handler(f'unknown dependency `{dep_name}`')
    if dep_options:
      handle_port_options(dep_name, dep_options, error_handler)
    handle_port_deps(dep_name, error_handler)


def split_port_options(arg):
  # Ignore ':' in first or second char of string since we could be dealing with a windows drive separator
  pos = arg.find(':', 2)
  if pos != -1:
    return arg[:pos], arg[pos + 1:]
  else:
    return arg, None


def handle_use_port_arg(settings, arg, error_handler=None):
  if not error_handler:
    def error_handler(message):
      handle_use_port_error(arg, message)
  name, options = split_port_options(arg)
  if name.endswith('.py'):
    port_file_path = name
    if not os.path.isfile(port_file_path):
      error_handler(f'not a valid port path: {port_file_path}')
    name = load_port(port_file_path)
  elif name not in ports_by_name:
    error_handler(f'invalid port name: `{name}`')
  ports_needed.add(name)
  if options:
    handle_port_options(name, options, error_handler)
  handle_port_deps(name, error_handler)
  return name


def get_needed_ports(settings, cflags_only=False):
  # Start with directly needed ports, and transitively add dependencies
  needed = OrderedSet(get_port_by_name(p.name) for p in ports if p.needed(settings))
  resolve_dependencies(needed, settings, cflags_only)
  return needed


def build_port(port_name, settings):
  port = get_port_by_name(port_name)
  port_set = OrderedSet([port])
  resolve_dependencies(port_set, settings)
  for port in dependency_order(port_set):
    port.get(Ports, settings, shared)


def clear_port(port_name, settings):
  port = get_port_by_name(port_name)
  port.clear(Ports, settings, shared)


def clear():
  Ports.erase()


def get_libs(settings):
  """Return a list of library to link against for the selected ports.

  This is called add link time only.

  Can have the side effect of building and installing the needed ports.
  """
  ret = []
  needed = get_needed_ports(settings)

  for port in dependency_order(needed):
    port.linker_setup(Ports, settings)
    # port.get returns a list of libraries to link
    ret += port.get(Ports, settings, shared)

  ret.reverse()
  return ret


def add_cflags(args, settings):
  """Add any compiler flags (e.g -Ifoo) needed by the selected ports.

  This is called during the compile phase.  Can also add/change settings.

  Can have the side effect of building and installing the needed ports.
  """
  # Legacy SDL1 port is not actually a port at all but builtin
  if settings.USE_SDL == 1:
    args += ['-I' + Ports.get_include_dir('SDL')]

  needed = get_needed_ports(settings, cflags_only=True)

  # Now get (i.e. build) the ports in dependency order.  This is important because the
  # headers from one port might be needed before we can build the next.
  for port in dependency_order(needed):
    # When using embuilder, don't build the dependencies
    if not os.getenv('EMBUILDER_PORT_BUILD_DEFERRED'):
      port.get(Ports, settings, shared)
    args += port.process_args(Ports)


def show_ports():
  sorted_ports = sorted(ports, key=lambda p: p.name)
  print('Available official ports:')
  for port in sorted_ports:
    if not port.is_contrib:
      print('   ', port.show())
  print('Available contrib ports:')
  for port in sorted_ports:
    if port.is_contrib:
      print('   ', port.show())


read_ports()
