#!/usr/bin/env python3
########################################################################
""" wing-install.py -- Simple installer for wing

Copyright (c) 1999-2026, Archaeopteryx Software, Inc.  All rights reserved.

Written by Stephan R.A. Deibel and John P. Ehresman

"""
########################################################################

# Has to run under all Python versions 2.0 to latest (what fun!)

import sys
import os
import stat
import grp

kDefaultPrefix = '/usr/local'
kTerminalWidth = 75
kVerbose = 1
kVersion = "11.1.0"
kBuild = "0"
kSourceDistFilename = 'source-package-%s.%s.tar' % (kVersion, kBuild)
kBinaryDistFilename = 'binary-package-%s.%s.tar' % (kVersion, kBuild)

kFilelistLocalName = 'file-list.txt'

kBuildRootOption = '--rpm-build-root'
kWinghomeOption = '--winghome'
kBinDirOption = '--bin-dir'
kRelocatableOption = '--relocatable'

def FindExecutable(cmdline):
  """ Find given executable for command line on the path """
  if cmdline[:1] == '"' or cmdline[:1] == '\'':
    exe_name = cmdline.split(cmdline[0])[1]
  else:
    exe_name = cmdline.split(' ')[0]
  exe_found = 0
  path_list = os.environ.get('PATH', '').split(os.pathsep)
  for path in path_list:
    if os.access(os.path.join(path, exe_name), os.X_OK):
      return 1
  return 0

def GetGTarCmd():
  """ Get command used to invoke Gnu tar """
  if sys.platform == 'darwin':
    osver = os.uname()[2].split('.')
    if int(osver[0]) >= 13:
      default_tar = 'tar'
    else:
      default_tar = 'gnutar'
  elif FindExecutable('gtar'):
    default_tar = 'gtar'
  else:
    default_tar = 'tar'
  return os.environ.get('WING_TAR_CMD', default_tar)
TAR_CMD = GetGTarCmd()

def FromOct(s):
  """ Converts string representing oct to a number. """
  
  if sys.version_info < (2, 6, 0):
    return eval('0' + s)
  else:
    return int(s, 8)

def AskYesOrNo(msg):
  """ Ask question with yes or no answer.  Returns true for yes, false for no.
  Message should not include '?'. """

  PrintMsg(msg + ' (y/N)? ', add_newline = 0)
  line = sys.stdin.readline()
  if len(line) > 0 and line[0].lower() == 'y':
    return 1
  else:
    return 0

def FindWrapPos(text):
  """ Find a word wrap position.  Text must not contain a '\n'. """

  if len(text) < kTerminalWidth:
    return None

  # Find last space before kTerminalWidth
  pos = kTerminalWidth - 1
  while pos >= 0:
    if text[pos].isspace():
      return pos
    pos = pos - 1
    
  # No space before kTerminalWidth, so find first after it
  pos = kTerminalWidth
  while pos < len(text):
    if text[pos].isspace():
      return pos
    pos = pos + 1
   
  return None

def PrintMsg(msg, wrap = 1, add_newline = 1, ostream = sys.stdout, indent2 = '     '):
  """ Print message to ostream, wrapping and adding newline if requested. """

  line_seq = msg.split('\n')
  for i in range(0, len(line_seq)):
    line = line_seq[i]
     
    wrap_pos = FindWrapPos(line)
    while wrap_pos != None and wrap_pos > len(indent2):
      ostream.write(line[:wrap_pos])
      ostream.write('\n')
      line = indent2 + line[wrap_pos + 1:]
      wrap_pos = FindWrapPos(line)

    ostream.write(line)
    if i < len(line_seq) - 1 or add_newline:
      ostream.write('\n')
  ostream.flush()

def ErrorExit(msg, exit_code = -1):
  """ Display error message and exit. """

  PrintMsg(msg, ostream = sys.stderr)
  sys.exit(exit_code)

def AskForInputLine(msg, default = '', ending_punc = '?'):
  """ Ask for a directory with the given prompt / default.  Return result. """

  if default != '':
    msg = '%s (default = %s)' % (msg, default)
  msg = msg + ending_punc + ' '
  PrintMsg(msg, add_newline = 0)
  line = sys.stdin.readline()
  if line.strip() == '':
    return default
  else:
    return line

def AskForDirectory(msg, default = ''):
  """ Ask for a directory with the given prompt / default.  Return result. """

  line = AskForInputLine(msg, default, ending_punc = '?')
  dir = os.path.expanduser(line.strip())
  if dir == '':
    dir = default
  dir = os.path.abspath(dir)
  if not os.path.exists(dir):
    if AskYesOrNo('%s does not exist, create it' % dir):
      RecursiveMakeDir(dir)

  if not os.path.isdir(dir):
    ErrorExit('Required directory does not exist, please run wing-install again')
  return dir

def UnpackTar(tar_file, target_dir, gzipped = 0):
  """ Unpack tar file into given directory.  gzipped should be set to 1 if tar file
  is compressed with gzip.  Returns list of file names unpacked. """

  # Unpack archive
  uid = os.getuid()
  gid = os.getgid()
  options = ('-x -v -k --directory="%s" --file="%s"' % (target_dir, tar_file))
  if gzipped:
    options = '-z ' + options
  tar_cmd = TAR_CMD + ' ' + options
  redirect_cmd = 'sh -c "export UMASK=0022; %s 2>&1"' % tar_cmd
  if kVerbose:
    PrintMsg("Tar command is %s" % redirect_cmd)
  child_out = os.popen(redirect_cmd, 'r')

  # Set ownership and group on all files (we don't use tar --group and --owner
  # because that doesn't work on all versions of tar)
  cmd = 'chown -R %s %s' % (str(uid), target_dir)
  if os.system(cmd) != 0:
    PrintMsg("Warning: Failed to change file owner in installation")
  else:
    PrintMsg("Set ownership of installation to user=%s" % str(uid))
  cmd = 'chgrp -R %s %s' % (str(gid), target_dir)
  if os.system(cmd) != 0:
    PrintMsg("Warning: Failed to change file group in installation")
  else:
    PrintMsg("Set ownership of installation to group=%s" % str(gid))

  # Get list of files in the archive  
  file_list = []
  try:
    line = child_out.readline()
    while line != '':
      if line[:len('tar:')] == 'tar:':
        fields = line.split(':')
        if len(fields) == 4 and fields[3].strip() == 'File exists':
          PrintMsg("The file '%s' already exists and won't be overwritten" 
                   % fields[1].strip())
      else:
        name = os.path.join(target_dir, line.strip())
        if kVerbose:
          PrintMsg('Writing %s' % name)
        file_list.append(name)
      
      line = child_out.readline()

  except OSError:
    ErrorExit("Failed to expand %s" % tar_file)
  else:
    if uid == 0:
      for name in file_list:
        try:
          os.chown(name, uid, gid)
        except OSError:
          pass
        
    return file_list
  
def CreateSymLink(target, alias, relative = 1):
  """ Creates symlink; ask if we should overwrite any current file or link with name 
  of the alias. """

  if os.path.exists(alias):
    if os.path.islink(alias):
      refd = os.readlink(alias)
      if not os.path.isabs(refd):
        refd = os.path.abspath(os.path.join(os.path.dirname(alias), refd))
      if os.path.exists(refd) and os.path.samefile(refd, target):
        if kVerbose:
          PrintMsg('%s symbolic link already exists' % alias)
        return
      msg = 'A link named %s (-> %s) exists, overwrite' % (alias, refd)
    elif os.path.isdir(alias):
      if len(os.listdir(alias)) != 0:
        ErrorExit("Unable to create symbolic link %s because a non-empty"
                  "directory with the same name exists" % alias)
      msg = 'An empty directory named %s exists, overwrite' % alias
    else:
      msg = 'A file named %s exists, overwrite' % (alias)
      
    res = AskYesOrNo(msg)
    if not res:
      return
    if kVerbose:
      PrintMsg('Removing %s' % alias)
    if os.path.isdir(alias):
      os.rmdir(alias)
    else:
      os.remove(alias)

  # Figure out the relative alias if one is requested and target is an abs path
  if relative and os.path.isabs(target):
    abs_alias = os.path.abspath(alias)
    common = os.path.commonprefix((target, abs_alias))
    while len(common) != 0 and common[-1] != os.sep:
      common = common[:-1]
    rel_target = target[len(common):]
    head, tail = os.path.split(abs_alias[len(common):])
    while head != '':
      rel_target = os.path.join(os.pardir, rel_target)
      head, tail = os.path.split(head)

    target = rel_target

  if kVerbose:
    PrintMsg('Creating symbolic link %s (-> %s)' % (alias, target))
  try:
    os.symlink(target, alias)
  except OSError:
    etype, exc, tb = sys.exc_info()
    ErrorExit('Can not create symlink "%s" to file "%s": %s' % (alias, target, exc))

def SetWingHomeInit(wing_dir, filename):
  """ Setup winghome initialization in given script file. """

  if kVerbose:
    PrintMsg('Setting up WINGHOME in %s' % filename)

  # Only used for wingdbstub.py now
  kXformMap = {
    'WINGHOME = None': ('WINGHOME="%s"' % wing_dir),
  }

  file = open(filename, 'r')
  lines = file.readlines()
  file.close()

  for i in range(0, len(lines)):
    sline = lines[i].strip()
    replace = kXformMap.get(sline)
    if replace is not None:
      prefix_len = lines[i].find(sline)
      lines[i] = prefix_len * ' ' + replace + '\n'
 
  file = open(filename, 'w')
  file.writelines(lines)
  file.close()

def FindArg(argv, name, remove = 1):
  """ Find value for a two arg command line sequence.  Args are removed from
  argv if remove is true. """

  for i in range(0, len(argv) - 1):
    if argv[i] == name:
      val = argv[i + 1]
      if remove:
        del argv[i:i + 2]
      return val

  return None
  
def RecursiveMakeDir(dir_name):
  """ Recursively make all directories.  Exits with an error if a something other
  than a directory already exists for any component. """

  assert dir_name != ''
  # Ensure lib dir exists and if it's not chack to see if it's a file and the create
  if not os.path.isdir(dir_name):
    if os.path.exists(dir_name):
      ErrorExit("A file named %s exists, please (re)move it or choose a different install directory"
                % dir_name)
    parent_dir, local_name = os.path.split(dir_name)
    RecursiveMakeDir(parent_dir)
    try:
      os.mkdir(dir_name, FromOct('755'))
    except OSError:
      ErrorExit('Unable to create directory %s, check permissions' % dir_name)

def GetFileInfoTuple(filename):
  """ Returns filename, type, size, & mtime of file on disk. """

  filename = os.path.abspath(filename)
  st = os.stat(filename)
  mtime = str(st[stat.ST_MTIME])
  size = str(st[stat.ST_SIZE])
  if os.path.islink(filename):
    ftype = 'symlink'
  elif os.path.isdir(filename):
    ftype = 'directory'
  else:
    ftype = 'file'

  return filename, ftype, size, mtime


def WriteFilelist(filelist_name, version, option_list, file_info_list):
  """ Write the first two lines of the filelist file. """

  file = open(filelist_name, 'w')

  file.write("Wing Pro file list version %s\n" % version)
  file.write("Options: %s\n" % ' '.join(option_list))
  for info in file_info_list:
    file.write(' '.join(info) + '\n')

  file.close()

def ReadFilelist(filelist_name):
  """ Attempts to read a filelist file with the given name.  If sucessful,
  a tuple of (version, option-list, file-list) is
  returned; each entry in file-list if a (filename, file-type, size, mtime)
  tuple.  If no such file exists, (kVersion, [], []) is returned. """

  empty_info = (kVersion, [], [])
  try:
    filelist = open(filelist_name, 'r')
  except IOError:
    return empty_info

  line1 = filelist.readline()
  if line1 == '':
    return empty_info
  fields = line1.strip().split(' ')
  if len(fields) == 0:
    return empty_info
  version = fields[-1]

  line2 = filelist.readline()
  if line2 == '':
    return empty_info
  option_list = line2.strip().split(' ')
  if option_list[0] == 'Options:':
    del option_list[0]

  files = []
  line = filelist.readline()
  while line != '':
    info = line.strip().split(' ')
    if len(info) > 4:  # Space(s) in the filename
      filename = ' '.join(info[:-3])
      info = (filename, info[-3], info[-2], info[-1])
    files.append(info)

    line = filelist.readline()
    
  filelist.close()

  return version, option_list, files

def RemoveInstalledFiles(winghome, filelist_name):
  """ Removes all files listed as installed in given filelist.txt file,
  including the filelist.txt file itself.  Returns list of files that were
  not deleted. """

  kept_files = []

  info = ReadFilelist(filelist_name)
  if info == None:
    ErrorExit('No %s file found.  This file is required for the uninstall script.'
              ' See the manual for instructions on how to remove an installation'
              ' by hand.' % filelist_name)
  version, option_list, file_list = info

  for filename, ftype, size, mtime in file_list:
    if os.path.exists(filename) or os.path.islink(filename):
      if os.path.islink(filename) and not os.path.exists(filename):
        do_delete = 1
      else:
        info = GetFileInfoTuple(filename)
        if info[1] == ftype and (ftype == 'directory'
                                 or (info[2] == size and info[3] == mtime)):
          do_delete = 1
        else:
          do_delete = AskYesOrNo('%s has changed since installation.\n'
                                 'Should it be deleted' % filename)
      if not do_delete:
        kept_files.append(filename)
      else:
        if kVerbose:
          PrintMsg('Deleting %s' % filename)
        if os.path.isdir(filename):
          if len(os.listdir(filename)) == 0:
            os.rmdir(filename)
        else:        
          os.remove(filename)

        # Delete dir if empty and under winghome, but not winghome
        dirname = os.path.dirname(filename)
        while dirname != winghome and dirname.find(winghome) == 0 \
              and len(os.listdir(dirname)) == 0:
          parent = os.path.dirname(dirname)
          if kVerbose:
            PrintMsg('Removing %s directory' % dirname)
          os.rmdir(dirname)
          if parent == dirname:
            break
          dirname = parent

  # Remove filelist.txt
  if kVerbose:
    PrintMsg('Deleting %s' % filelist_name) 
  os.remove(filelist_name)

  return kept_files

def DoUninstall(argv):
  """ Uninstall. """

  winghome = FindArg(argv, kWinghomeOption)
  if winghome == None:
    ErrorExit(kWinghomeOption + " must be specified when uninstalling")
  winghome = os.path.expanduser(winghome)

  if kVerbose:
    PrintMsg('Removing installation in %s' % winghome)

  if sys.platform[:6] == 'linux':
    # XXX Old code; save for now in case there are problems with the new system
    #uninstall_desktop = AskYesOrNo("Do you want to uninstall icons and desktop menu items (requires sudo)")
    #if uninstall_desktop:
      #cmd = 'sudo %s/bin/PyCore/python -OSE %s/resources/linux/desktop/install-linux-desktop.py --uninstall' % (winghome, winghome)
      #os.environ['WINGHOME'] = winghome
      #err = os.system(cmd)
      #PrintMsg('Icon/menu removal returned err=%d' % err)
    # Uninstall icon and menu items (if not running as root, uninstalls them for user only)
    cmd = '/bin/bash %s/resources/linux/desktop/install-linux-desktop.sh --uninstall' % winghome
    err = os.system(cmd)
    PrintMsg('Icon/menu removal returned err=%d' % err)
  
  filelist_name = os.path.join(winghome, kFilelistLocalName)
  RemoveInstalledFiles(winghome, filelist_name)

  if len(os.listdir(winghome)) == 0:
    if kVerbose:
      PrintMsg('Removing %s directory' % winghome)
    os.rmdir(winghome)
  else:
    PrintMsg('Files remain in %s.  You may want to remove them and remove'
             ' the directory.' % winghome)

  PrintMsg('Removal of installation successful.  Thank you for using'
           ' Wing Pro.\n')

def WriteExecutableScript(filename, winghome, relocatable):
  """ Write a script that sets winghome and then sources bootstrap/run-wing.sh. """
  
  # The script, note that indentation of result matters
  if not relocatable:
    txt = ("""#!/bin/sh\n"""
           """WINGHOME="%s"; export WINGHOME\n"""
           """. "${WINGHOME}/bootstrap/run-wing.sh"\n"""
           % winghome)
  else:
    txt = ("""#!/bin/sh\n"""
           """alias cd=xxx\n"""
           """unalias cd\n"""
           """alias pwd=xxx\n"""
           """unalias pwd\n"""
           """alias dirname=xxx\n"""
           """unalias dirname\n"""           
           """DIR=`dirname "$0"`\n"""
           """WINGHOME=`cd "${DIR}"; pwd` export WINGHOME\n"""
           """. "${WINGHOME}/bootstrap/run-wing.sh"\n""")
  f = open(filename, "w")
  f.write(txt)
  f.close()

def DoInstall(argv):
  """ Perform an installation. """

  build_root = FindArg(argv, kBuildRootOption, remove = 0)

  # Find dir for winghome
  winghome = FindArg(argv, kWinghomeOption)
  if winghome == None:
    winghome = AskForDirectory('Where do you want to install the'
                               ' support files for Wing Pro',
                               '/usr/local/lib/wingpro11')

  # Adjust for build root
  if build_root != None:
    if not os.path.isabs(winghome):
      ErrorExit("The wing installation directory must be absolute path when using %s." 
                % kBuildRootOption)

  filelist_name = os.path.join(winghome, 'file-list.txt')
  files = []

  # Detect which install to do
  install_binary = '--install-binary' in argv
  install_source = '--install-source' in argv
  if not install_binary and not install_source and not kBuildRootOption in argv and not kRelocatableOption in argv:
    src_exists = os.path.exists(kSourceDistFilename) 
    bin_exists = os.path.exists(kBinaryDistFilename)
    if src_exists and bin_exists:
      install_binary = AskYesOrNo("Install binary distribution")
      install_source = AskYesOrNo("Install source distribution")
    else:
      install_source = src_exists
      install_binary = bin_exists
  if not install_binary and not install_source:
    install_binary = True

  # Make winghome & bin dirs
  RecursiveMakeDir(winghome)

  # If existing files, ask if we should continue
  if len(os.listdir(winghome)) != 0 and not os.path.exists(filelist_name):
    res = AskYesOrNo("Files exist in %s, overwrite" % winghome)
    if not res:
      ErrorExit("Existing files found in %s" % winghome)

  # Read file info & see what's already installed
  version, option_list, file_info_list = ReadFilelist(filelist_name)

  # Always remove old version when installing binaries
  if len(file_info_list) != 0 and install_binary:
    msg = ('Upgrading from version %s: removing its files.  You will be given a chance to'
           ' keep any file that has been modified; files kept will be renamed to'
           ' <filename>.oldsaved.' % version)
    PrintMsg(msg)
    kept_files = RemoveInstalledFiles(winghome, filelist_name)
    for filename in kept_files:
      os.rename(filename, filename + '.oldsaved')
    #files.extend(kept_files)
    version, option_list, file_info_list = ReadFilelist(filelist_name)

  # Check for already installed packages
  if 'binary' in option_list:
    PrintMsg("Not installing binary distribution because it's already installed")
    install_binary = 0
  
  if 'source' in option_list:
    PrintMsg("Not installing source distribution because it's already installed")
    install_source = 0

  if install_binary:
    relocatable = kRelocatableOption in argv
  else:
    relocatable = False

  if install_binary and not relocatable:
    bin_dir = FindArg(argv, kBinDirOption)
    if bin_dir == None:
      bin_dir = AskForDirectory('Where do you want to install links to the'
                                ' Wing Pro startup scripts', '/usr/local/bin')
    RecursiveMakeDir(bin_dir)
  else:
    bin_dir = None
    
  if install_source:
    PrintMsg('Installing source')
    files.extend(UnpackTar(kSourceDistFilename, winghome))
    option_list.append('source')

  if install_binary:
    # Unpack the tar archive
    PrintMsg('Installing binaries')
    files.extend(UnpackTar(kBinaryDistFilename, winghome))
    option_list.append('binary')

    # Set up the WINGHOME inits
    if build_root == None:
      real_winghome = winghome
    else:
      real_winghome = winghome[len(build_root):]
    if not relocatable:
      SetWingHomeInit(real_winghome, os.path.join(winghome, 'wingdbstub.py'))
    WriteExecutableScript(os.path.join(winghome, 'wing'), real_winghome, relocatable)

    # Create symlinks if binary dir was specified
    if bin_dir != None and not relocatable:
      wing_symlink = os.path.abspath(os.path.join(bin_dir, 'wing11'))
      CreateSymLink(os.path.abspath(os.path.join(winghome, 'wing')), wing_symlink)
      files.append(wing_symlink)
      
    # Remove the install scripts if building RPM/Debian or relocatable package
    if build_root is not None or relocatable:
      PrintMsg("Removing unneeded files for RPM/Debian or relocatable packages")
      os.unlink(os.path.join(winghome, 'wing-uninstall'))
      os.unlink(os.path.join(winghome, 'build-files', 'wing-install.py'))

  # Write file-list.txt (but not in RPM/Debian or relocatable package)
  if build_root is None and not relocatable:
    PrintMsg("Writing file-list.txt")
    for name in files:
      file_info_list.append(GetFileInfoTuple(name))
    WriteFilelist(filelist_name, version, option_list, file_info_list)
  else:
    PrintMsg("Not writing file-list.txt (installing for RPM/Debian or relocatable dist)")

  if sys.platform[:6] == 'linux' and install_binary and not kBuildRootOption in argv and not relocatable:
    # XXX Old code; save for now in case there are problems with the new approach
    #install_desktop = AskYesOrNo("Do you want to install icons and desktop menu items (requires sudo)")
    #if install_desktop:
      #cmd = 'sudo %s/bin/PyCore/python -OSE %s/resources/linux/desktop/install-linux-desktop.py' % (winghome, winghome)
      #os.environ['WINGHOME'] = winghome
    #err = os.system(cmd)
    #PrintMsg('Icon/menu install returned err=%d' % err)
    # Install icon and menu items (if not running as root, installs them for user only)
    cmd = '/bin/bash %s/resources/linux/desktop/install-linux-desktop.sh --install' % winghome
    err = os.system(cmd)
    PrintMsg('Icon/menu install returned err=%d' % err)
    
  if install_binary:
    PrintMsg('Done installing.  Make sure that %s is in your path and type'
             ' "wing11" to start Wing Pro.\n' % bin_dir)
  elif install_source:\
    PrintMsg('Done installing.')

if __name__ == '__main__':
  if '--help' in sys.argv:
    ErrorExit("Usage %s [--verbose] [--uninstall] [--install-binary] [--install-source]"
              " [--winghome <install directory>]"
              " [--bin-dir <bin dir for symlinks>]"
              " [--rpm-build-root <dir rpm is building in>]"
              % sys.argv[0])

  if '--verbose' in sys.argv:
    kVerbose = 1

  # Set up umask so files created don't have write permissions
  os.umask(FromOct('022'))
  os.environ['UMASK'] = '0022'
  
  if '--uninstall' in sys.argv:
    DoUninstall(sys.argv)
  else:
    DoInstall(sys.argv)

