# Shared utilities for starting up Wing
#
# Copyright (c) 2000-2018, Archaeopteryx Software, Inc.  All rights reserved.
#
# Written by Stephan R.A. Deibel and John P. Ehresman

import sys, os
import shutil
import stat

import _winghome

# This code needs to work with all supported versions of Python

#-----------------------------------------------------------------------
def NP_GetPlatform():
  """Get the canonical platform identifier. One of: macos, win32, 
  linux-x32, linux-x64, linux-arm32, or linux-arm64.  Note that osx is used for 
  the macOS with the x86_64"""

  # Note: These cannot be changed without changing many other things

  if sys.platform == 'darwin':
    return 'macos'
  
  if sys.platform == 'win32':
    return 'win32'

  uname = os.uname()
  if uname[-1].startswith(('arm', 'aarch')):
    proc = 'arm'
  else:
    proc = 'x'

  maxsize = sys.maxsize

  if maxsize > (1 << 32):
    arch = proc + '64'
  else:
    arch = proc + '32'

  if sys.platform.startswith('linux'):
    return 'linux-' + arch
  else:
    return sys.platform + '-' + arch

#-----------------------------------------------------------------------
def NP_SetupWingHomeModule(winghome, logger=None, print_errors=False):
  """Set up kWingHome and kUserSettings in the winghome module.  kCacheDir
  is set later, after the mergeimporter is in place."""

  try:

    import os

    _winghome.kWingHome = winghome

    user_settings = _NP_GetUnicodeEnvValue('WINGDB_USERSETTINGS', None, logger, winghome)
    if user_settings is None:
      minimalconfig = _NP_LoadModuleFromBootstrap('minimalconfig', winghome)
      bootstrap_vinfo = _NP_LoadModuleFromBootstrap('bootstrap_vinfo', winghome)
      user_settings = minimalconfig.GetUserSettingsDir(bootstrap_vinfo.kInstallVersion[0])
    else:
      del os.environ['WINGDB_USERSETTINGS']

    _winghome.kUserSettings = user_settings

    return winghome, user_settings

  except:
    if print_errors:
      import traceback
      traceback.print_exc(file=sys.__stderr__)

    return None, None

#-----------------------------------------------------------------------
def NP_CreateMetaImporter(winghome, user_settings, domain, logger):
  """Create meta importer to support .wingcode files and updates.  This is
  used by the IDE and also by the debugger and remote agent.  Adds
  the bootstrap directory and all the code directories for the given
  domain (either 'dbg' for debugger and remote agent, or 'ide' for
  the IDE).  Returns the created importer."""

  assert winghome is not None
  assert user_settings is not None
  assert logger is not None

  # Load latest updateutils with some patchable functionality that can be
  # used in the bootstrap process
  updateutils = _NP_LoadFromWingbase(winghome, user_settings, logger, 'updateutils')

  # Get all the directories that can contain updates
  update_roots = _NP_GetUpdateRoots(winghome, user_settings)
  update_dirs = _NP_GetUpdateDirs(winghome, user_settings)
  
  # Try to remove update directories that no longer match the install version
  bootstrap_vinfo = _NP_LoadModuleFromBootstrap('bootstrap_vinfo', winghome)
  failed_remove = updateutils.RemoveOldUpdates(update_roots, bootstrap_vinfo.kInstallVersion)
  if failed_remove:
    logger.write("Failed to remove the following old update directories:\n")
    logger.write('\n'.join(failed_remove) +'\n')
    
  # Try to merge old update directories; this may fail but only raises an exception on
  # implementation error, which we treat as likely corruption
  try:
    code_dirs = NP_GetCodeDirs(winghome, user_settings, logger=logger)
    fatal, error_list = updateutils.MergeUpdateDirs(code_dirs)
    if error_list:
      logger.write('\n'.join(error_list) + '\n')
    if fatal:
      raise RuntimeError('corrupt-updates')
  except:
    logger.write("FAILED TO MERGE UPDATE DIRS; MARKING UPDATES CORRUPT\n")
    _winghome.kLoadUpdates = False
    gCachedCodeDirs[0] = None
    updateutils.MarkUpdatesCorrupt(update_dirs)

  logger.write("Creating meta importer for domain %s\n" % (domain,))
  logger.write("winghome=%s\n" % winghome)

  # Get the mergeimporter from the latest update where it's found
  mergeimporter = _NP_LoadFromWingbase(winghome, user_settings, logger, 'mergeimporter')

  # Create the meta importer instance
  importer = mergeimporter.CMergeDirImporter(logger=logger)  

  # Add unpatchable bootstrap dir to the importer
  importer.AddDir(winghome+'/bootstrap')

  # Get the directories that could contain code; need to call NP_GetCodeDirs
  # again here in case the merge corrupted updates
  code_dirs = NP_GetCodeDirs(winghome, user_settings, logger=logger)
  code_dirs.reverse()
  
  # Add each possible code directory from updates and the install
  for ver, code_dir in code_dirs:

    # Add each code directory:  Code may be in /bin if running release or 
    # /src if running from sources
    bin_dir = code_dir + '/bin/%s/src' % domain
    src_dir = code_dir + '/src'
    for full in (bin_dir, src_dir):
      full = os.path.normpath(full)
      if os.path.isdir(full):
        logger.write('Adding code directory to merge importer: %s\n' % full)
        _NP_AddMergeDir(importer, full)

  # Add arch-specific external packages if domain is 'ide'
  if domain == 'ide':
    ext_pkgs = winghome + '/bin/__os__/' + NP_GetPlatform() + '/external-packages'
    ext_pkgs = os.path.normpath(ext_pkgs)
    if os.path.isdir(ext_pkgs):
      logger.write('Adding external packages directory to merge importer: %s\n' % ext_pkgs)
      _NP_AddMergeDir(importer, ext_pkgs)

  # Add to meta path
  sys.meta_path.insert(0, importer)

  # Set up also kCacheDir in _winghome, which we couldn't do until now
  import minimalconfig
  _winghome.kCacheDir = minimalconfig.kCacheDir
  
  # Must unload this module so Wing can debug itself
  del sys.modules['minimalconfig']
  
  return importer

#-----------------------------------------------------------------------
gCachedCodeDirs = [None]
def NP_GetCodeDirs(winghome, user_settings, filter=None, logger=None):
  """ Return list of (ver, dirname) for all code directories. The last one is always
  winghome, for which the version is the installation version. The others are update
  directories, with most recent update first."""

  assert winghome is not None
  assert user_settings is not None

  # Cache the code directories so we only look at them once and use only the
  # ones that were present at startup; this avoids mixing in files from an
  # update applied while running
  if gCachedCodeDirs[0] is not None:
    return gCachedCodeDirs[0]
  
  bootstrap_vinfo = _NP_LoadModuleFromBootstrap('bootstrap_vinfo', winghome)

  # If updates are disabled for any reason, just use winghome
  if not _winghome.kLoadUpdates:
    gCachedCodeDirs[0] = [(bootstrap_vinfo.kInstallVersion, winghome)]
    return gCachedCodeDirs[0]

  update_dirs = _NP_GetUpdateDirs(winghome, user_settings)

  retval = []
  for update_dir in update_dirs:

    # Check if failed update merge has tagged updates as corrupt; in that
    # case run without installing any updates
    if os.path.exists(os.path.join(update_dir, 'CORRUPT.txt')):
      if logger:
        logger.write("WARNING: UPDATES CORRUPT AND WILL NOT BE LOADED!\n")
        logger.write("WARNING: To fix this, remove the following %i directories from disk "
                     "and reinstall updates:\n" % len(update_dirs))
        logger.write('\n'.join(update_dirs) + '\n')
      _winghome.kLoadUpdates = False
      return NP_GetCodeDirs(winghome, user_settings, filter, logger)
    
    try:
      name_list = os.listdir(update_dir)
    except (OSError, IOError):
      name_list = []

    name_list.sort()

    for name in name_list:
      fp = update_dir + '/' + name
      if callable(filter) and not filter(fp):
        continue

      ver = _NP_NameToVersion(name)
      if ver is None or ver <= bootstrap_vinfo.kInstallVersion:
        continue
      
      retval.append((ver, fp))

  retval.sort()
  retval.reverse()

  retval.append((bootstrap_vinfo.kInstallVersion, winghome))

  gCachedCodeDirs[0] = retval
  return gCachedCodeDirs[0]

#-----------------------------------------------------------------------
def NP_GetWingFilename(*parts):
  """Return filename in wing installation or in the latest active update
  that has that file.  This should not be used with directories, where
  traversing the path returned from _NP_FindUpdateFiles() is needed instead,
  to properly interleave files in various updates + wing installation dir;
  except in the case where that directory is added to the whole_dirs clause
  in updateutils.py"""

  assert all(not os.path.isabs(p) and os.sep != p[0] and os.sep != p[-1]
             for p in parts)

  name = os.sep.join(parts)
  matching = _NP_FindUpdateFiles(name)

  # File doesn't exist; return original path in winghome
  if not matching:
    return _winghome.kWingHome + '/' + name

  return matching[0]


#########################################################################
# Local utilities
#########################################################################

#----------------------------------------------------------------------
def _NP_GetUpdateDirname(winghome):
  
  bootstrap_vinfo = _NP_LoadModuleFromBootstrap('bootstrap_vinfo', winghome)
  base_version = '.'.join([str(i) for i in bootstrap_vinfo.kInstallVersion])

  return 'from_' + base_version
  
#----------------------------------------------------------------------
def _NP_GetUpdateRoots(winghome, user_settings):
  """Get all directories that may contain updates"""
  
  return [
    user_settings + '/updates',
    winghome + '/updates', 
  ]

#----------------------------------------------------------------------
def _NP_GetUpdateDirs(winghome, user_settings):
  """Get all directories that may contain updates"""
  
  update_roots = _NP_GetUpdateRoots(winghome, user_settings)
  retval = []
  for root in update_roots:
    retval.append(root + '/' + _NP_GetUpdateDirname(winghome))
    
  return retval

#-----------------------------------------------------------------------
def _NP_FindUpdateFiles(pathname):
  """ Find list of filenames where pathname exists within an update dir
  or WINGHOME, most recent update first. """

  if os.path.isabs(pathname):
    return pathname

  all_updates = NP_GetCodeDirs(_winghome.kWingHome, _winghome.kUserSettings)
  all_updates = [u[1] for u in all_updates]

  matching = []
  for dirname in all_updates:
    assert not os.path.isabs(pathname)
    # XXX Determine current domain?
    fullname = os.path.join(dirname, pathname)
    if os.path.exists(fullname):
      matching.append(fullname)
    fullname = os.path.join(dirname, 'bin', 'dbg', pathname)
    if os.path.exists(fullname):
      matching.append(fullname)
    fullname = os.path.join(dirname, 'bin', 'ide', pathname)
    if os.path.exists(fullname):
      matching.append(fullname)

  return matching

#-----------------------------------------------------------------------
def _NP_LoadModuleFromBootstrap(mod_name, winghome=None):
  """Load the given module from the bootstrap area in winghome. Returns the module."""

  import os

  # This file is always in WINGHOME/bootstrap
  if winghome is None:
    winghome = os.path.dirname(os.path.dirname(__file__))

  mod_full_path = winghome + '/bootstrap/' + mod_name + '.py'

  # Add module's directory to sys.path so it can import other things from same directory
  orig_sys_path = sys.path[:]
  sys.path.insert(0, os.path.dirname(mod_full_path))
  try:
    mod = _NP_LoadModuleFromDir(mod_name, os.path.dirname(mod_full_path))
  finally:
    sys.path = orig_sys_path

  return mod

#-----------------------------------------------------------------------
def _NP_LoadFromWingbase(winghome, user_settings, logger, modname):
  """Load the given *.py source module from the wingbase area in the latest update that
  provides it. Returns the module."""

  import os

  def find_mod_file():
    code_dirs = NP_GetCodeDirs(winghome, user_settings, logger=logger)
    for ver_tuple, code_dir in code_dirs:
      look_in = [
        code_dir + '/src/wingbase', 
        code_dir + '/bin/dbg/src/wingbase', 
        code_dir + '/bin/ide/src/wingbase', 
      ]
      for dirname in look_in:
        fn = os.path.join(dirname, '%s.py' % modname)
        if os.path.exists(fn):
          return fn
    return None

  fn = find_mod_file()
  if fn is None:
    logger.write("Could not load %s\n" % modname)
    return None
  
  mod = _NP_LoadModuleFromDir(modname, os.path.dirname(fn))
  return mod

#-----------------------------------------------------------------------
def _NP_AddMergeDir(importer, full):
  """Add a single directory to the merge importer, including the
  cython-compiled and __os__ sub-directories for platform-specific files.
  """

  full = os.path.normpath(full)

  # Add the directory itself
  importer.AddDir(full)

  # Find descendent directories with .so / .pyd files
  try:
    descendant_dirs = []
    for extended, subdir_list, file_list in os.walk(full):
      for name in ('cython-compiled', '__os__'):
        if name in subdir_list:
          descendant_dirs.append(os.path.join(extended, name))
          # Don't descend into dir since cython-compiled contain __os__
          subdir_list.remove(name)
  except:
    descendant_dirs = []

  full_parts = full.split(os.sep)
  
  def get_os_platform_dir(subdir):
    """ Get the platform subdir of an __os__ dir.  Normally just returns
    the __os__/<platform> but on win32 during development a shadow dir
    with copies of all .pyd and .dll files is returned so that the originals
    can be deleted when build gets run.  This is to workaround the file locking
    of .pyd and .dll files in use. """
    
    plat_dir = subdir+'/'+NP_GetPlatform()
    
    if not __debug__:
      return plat_dir
    if sys.platform != 'win32':
      return plat_dir
    
    winghome = _winghome.kWingHome
    build_temp = winghome+'/build-files/build-temp'
    if not os.path.isdir(build_temp):
      return plat_dir

    def list_pyds_and_dlls(dirname):
  
      try:
        return [n for n in os.listdir(dirname)
                if (os.path.isfile(dirname + '/' + n) 
                    and n.lower().endswith(('.pyd', '.dll', '.pdb')))]
      except OSError:
        return []

    try:
      name_list = list_pyds_and_dlls(plat_dir)
      
      shadow_pyd_dir = build_temp + '/shadow-pyd-dirs/' + plat_dir[len(winghome)+1:]
      if not os.path.isdir(shadow_pyd_dir):
        os.makedirs(shadow_pyd_dir)
      
      for subdir_number in range(5):
        copy_into_maybe = False

        maybe_dir = shadow_pyd_dir+'/'+str(subdir_number)
        if os.path.exists(maybe_dir):
          maybe_name_list = list_pyds_and_dlls(maybe_dir)
          if set(maybe_name_list) == set(name_list):
            use_maybe = True
            for name in name_list:
              try:
                plat_name_st = os.stat(plat_dir+'/'+name)
                maybe_name_st = os.stat(maybe_dir+'/'+name)
              except OSError:
                use_maybe = False
              else:
                if (plat_name_st.st_size !=  maybe_name_st.st_size
                    or abs(plat_name_st.st_mtime - maybe_name_st.st_mtime) > .01):
                  use_maybe = False
  
            if use_maybe:
              return maybe_dir
          
          # Otherwise see if we can delete all files and reuse
          can_delete = True
          for name in maybe_name_list:
            try:
              f = open(maybe_dir+'/'+name, 'ab')
              f.close()
            except IOError:
              can_delete = False
          if can_delete:
            for name in maybe_name_list:
              os.remove(maybe_dir+'/'+name)
            copy_into_maybe = True            
                      
        else:
          os.mkdir(maybe_dir)
          copy_into_maybe =  True
          
        if copy_into_maybe:
          for name in name_list:
            shutil.copy2(plat_dir+'/'+name, maybe_dir+'/'+name)
            
          return maybe_dir
        
      return plat_dir
      
    except:
      import traceback
      traceback.print_exc()
      return plat_dir

  # Add __os__ subdirs and cython-compiled directories as if the modules were in the package
  for subdir in descendant_dirs:
    subdir = os.path.normpath(subdir)
    subdir_parts = subdir.split(os.sep)
    pkg_name_parts = subdir_parts[len(full_parts):-1]
    if len(pkg_name_parts) != 0:
      pkg_prefix = '.'.join(pkg_name_parts) + '.'
      
      # Always add __os__ subdirs -- extension modules located here are either only loaded in the IDE,
      # compiled with XAPI so they work with any Python, or code is designed to fall back gracefully 
      # if the extension module cannot be loaded
      if subdir_parts[-1] == '__os__':
        plat_dir = get_os_platform_dir(subdir)
        importer.AddDir(plat_dir, pkg_prefix)
        
      # cython-compiled directories are only added if the stamp file exists, which indicates 
      # that the compilation matches the running version of Python
      else:
        stamp_name = 'python%s.%s' % sys.version_info[:2]
        if hasattr(sys, 'getobjects'):
          stamp_name += '-debug'
        else:
          stamp_name += '-release'
        if os.path.exists(subdir+'/'+stamp_name):
          plat_dir = get_os_platform_dir(subdir+'/__os__')
          importer.AddDir(plat_dir, pkg_prefix)

#-----------------------------------------------------------------------
def _NP_NameToVersion(name):
  """Try to convert given directory name to a 4-int version tuple.  Returns
  None if it's not a version directory."""

  parts = name.split('.')
  if len(parts) not in (3, 4):
    return None
  try:
    retval = tuple([int(p) for p in parts])
    if len(retval) == 3:
      return retval + (0, )
    else:
      return retval
  except:
    return None

#-----------------------------------------------------------------------
def _NP_GetUnicodeEnvValue(name, default=None, logger=None, winghome=None):
  """ Get the environment value as a unicode instance.  Needed 
  because getting the unicode value on win32 involves an win32 api call """

  if sys.version_info >= (3, 0):
    return os.environ.get(name, default)

  if sys.platform == 'win32':
    minimalwin32 = _NP_LoadModuleFromBootstrap('minimalwin32', winghome)
    value = minimalwin32.GetEnvironmentVariable(name, default)
    return value

  value = os.environ.get(name)
  if value is None:
    return default

  encoding = sys.getfilesystemencoding()
  if encoding is None:
    encoding = sys.getdefaultencoding()

  return unicode(value, encoding, 'replace')

#-----------------------------------------------------------------------
def _NP_LoadModuleFromDir(mod_name, dir_name):
  """ Load a module from a specific directory.  Will raise ImportError or any 
  other exception that import does if module can't be loaded """

  if sys.hexversion >= 0x03070000:

    import importlib.machinery, importlib.util
    spec = importlib.machinery.PathFinder.find_spec(mod_name, [dir_name])
    if spec is None:
      return None
    mod = importlib.util.module_from_spec(spec)
    if mod is None:
      return None
    spec.loader.exec_module(mod)
    return mod

  else:
    import imp

    fp, pathname, description = imp.find_module(mod_name, [dir_name])
    try:
      return imp.load_module(mod_name, fp, pathname, description)
    finally:
      fp.close()
      
