# 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: osx, win32, linux-x32, linux-x64,
  linux-arm32, or linux-arm64"""

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

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

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

  try:
    maxsize = sys.maxsize
  except:
    maxsize = sys.maxint

  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-2.7' 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_dirs = _NP_GetUpdateDirs(winghome, user_settings)
  
  # Try to remove update directories that are older than the install version
  bootstrap_vinfo = _NP_LoadModuleFromBootstrap('bootstrap_vinfo', winghome)
  failed_remove = updateutils.RemoveOldUpdates(update_dirs, 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" % 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 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')

  # 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"""

  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_GetUpdateDirs(winghome, user_settings):
  """Get all directories that may contain updates"""
  
  return [
    user_settings + '/updates',
    winghome + '/updates', 
  ]
#-----------------------------------------------------------------------
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?  Or merge these two?
    fullname = os.path.join(dirname, 'bin', 'ide-2.7', 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, 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 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 + '/bin/ide-2.7/src/wingbase', 
        code_dir + '/bin/dbg/src/wingbase', 
        code_dir + '/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)

  # Add cython-compiled and __os__ subdirs 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) + '.'
      if subdir_parts[-1] == '__os__':
        importer.AddDir(subdir+'/'+NP_GetPlatform(), pkg_prefix)
      else:
        # cython-compiled only added if stamp file exists
        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):
          importer.AddDir(subdir+'/__os__/'+NP_GetPlatform(), 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()
      
