import os
import stat
import sys
import traceback
import time
import signal
import tempfile

from wingbase import destroyable
from wingbase import timeout
from wingbase import spawn
from wingbase import textutils
from wingbase import updateutils

import config

if sys.version_info.major >= 3:
  from external import pyyaml3 as pyyaml
else:
  from external import pyyaml

from plugins.cluster import api

_ = api._

gClusters = {}
class CCluster(destroyable.CDestroyable, api.CCluster):

  def __init__(self, config_path, io_cb):

    destroyable.CDestroyable.__init__(self)
    api.CCluster.__init__(self, config_path, io_cb)
    
    gClusters[self.config_path] = self

    self.__status = self.kTerminated
    self.__derived_config_path = None
    self._process = None
    self._terminating = False
    self.__terminate_cbs = set()
    self.__pending_launch = None
  
  def _destroy_impl(self):
    
    self.Terminate()
    
    if gClusters.get(self.config_path, None) is self:
      del gClusters[self.config_path]
    
  def IsRunning(self):
    
    return not self.destroyed() and self._process is not None and not self._terminating
  
  def GetStatus(self):
    
    return self.__status

  def _UpdateStatus(self, status):
    
    if self.destroyed():
      return
    
    self.__status = status
    CClusterSystem._CallStatusCallbacks(self.config_path, status)

  def Launch(self, config_path=None, env={}, mappings={}):
    
    if self.IsRunning():
      return
    
    if self._terminating:
      self.__pending_launch = (config_path, env, mappings)
      return

    self._UpdateStatus(self.kStarting)
    
    self.__pending_launch = None      
    self._terminating = False
    
    if config_path:
      dirname = os.path.dirname(config_path)
      cmd = ['docker-compose', '--no-ansi', '-f', config_path, 'up']
      self.__derived_config_path = None
    elif env or mappings:
      config_path = CClusterSystem()._CreateDerivedConfiguration(self.config_path, env, mappings)
      dirname = os.path.dirname(config_path)
      cmd = ['docker-compose', '--no-ansi', '-f', config_path, 'up']
      self.__derived_config_path = config_path
    else:
      dirname = os.path.dirname(self.config_path)
      cmd = ['docker-compose', '--no-ansi', 'up']
      self.__derived_config_path = None
    
    def terminated(p):
      if not self._terminating:
        self.io_cb('error', _("Docker Compose for {} terminated unexpectedly; err={}").format(self.config_path,
                                                                                         proc.GetExitCode()))
        self._UpdateStatus(self.kStartFailed)
      else:
        self._UpdateStatus(self.kTerminated)

      if self.__derived_config_path is not None and self.__derived_config_path != self.config_path:
        os.unlink(self.__derived_config_path)
        self.__derived_config_path = None
      
      self._process = None
      terminate_cbs = self.__terminate_cbs
      
      self.__CallTerminateCBs(terminate_cbs)      
    
      self.destroy()

    did_fail = [False]
    def start_failed(p, exc_text):
      self.io_cb('error', _("Failed to start Docker Compose: {}").format(cmd))
      self.io_cb('error', exc_text)
      self._UpdateStatus(self.kStartFailed)
      did_fail[0] = True
    
    def output(p, txt):
      self.io_cb('output', txt)
      
    proc = spawn.CChildProcess(cmd, env=config.gStartupEnv, child_pwd=dirname)
    proc.connect_while_alive('terminated', terminated, self)
    proc.connect_while_alive('start-failed', start_failed, self)
    proc.connect_while_alive('received-output', output, self)

    self.io_cb('info', _("Running: {}").format(spawn._win32_cmd_line_from_argv(cmd)))
    proc.Start()

    # XXX This is actually premature, but probably harmlessly so; may want to set things
    # XXX up so our derived config file adds a container that prints something and has
    # XXX depends_on for all the other containers
    if not did_fail[0]:
      self._UpdateStatus(self.kRunning)
    
    self._process = proc
  
  def GetCommandLine(self, service, cmdline, startdir=None, env={}, pseudo_tty=False):
    
    rundir = os.path.dirname(self.config_path)
    
    cmd = ['docker-compose', '-f', self.config_path, 'exec']

    self._AddPtyToCmdLine(cmd, pseudo_tty)
    self._AddStartDirToCmdLine(cmd, startdir)
    self._AddEnvToCmdLine(cmd, env)
      
    cmd.append(service)
    cmd.extend(cmdline)
      
    return rundir, cmd
  
  def Terminate(self, terminated_cb=None):

    if callable(terminated_cb):
      self.__terminate_cbs.add(terminated_cb)
    
    if self._process is None or self._terminating:
      return

    self._UpdateStatus(self.kTerminating)
    self._terminating = True
    
    self._process.ClosePipeToChild()
    if sys.platform != 'win32':
      self._process.SendNonWin32Signal(signal.SIGINT)
    
    self.__RunDockerComposeDown()
    
  def _AddPtyToCmdLine(self, cmd, pseudo_tty):
    if not pseudo_tty:
      cmd.append('-T')
        
  def _AddStartDirToCmdLine(self, cmd, startdir):
    if startdir:
      cmd.append('-w')
      cmd.append(startdir)
        
  def _AddEnvToCmdLine(self, cmd, env):
    for key, value in env.items():
      cmd.append('-e')
      cmd.append('{}={}'.format(key, value))
    
  def __CallTerminateCBs(self, terminate_cbs):
    
    for cb in terminate_cbs:
      try:
        cb()
      except:
        if __debug__:
          raise
        print(_("Cluster termination callback failed: {}").format(cb))
        print(traceback.format_exc(limit=10))
    
    self._UpdateStatus(self.kTerminated)
    
  def __RunDockerComposeDown(self):
    
    cmd = ['docker-compose', '--no-ansi', '-f', self.config_path, 'kill']
    dirname = os.path.dirname(self.config_path)
    
    def terminated(p):
      if self.destroyed():
        return
      if self.__pending_launch is not None:
        self.Launch(*self.__pending_launch)
    
    def start_failed(p, exc_text):
      self.io_cb('error', _("Failed to shut down Docker Compose: {}").format(cmd))
      self.io_cb('error', exc_text)
    
    def output(p, txt):
      if not self.destroyed():
        self.io_cb('output', txt)
      else:
        print("Cluster output after CCluster destroyed:")
        print(txt)
      
    proc = spawn.CChildProcess(cmd, env=config.gStartupEnv, child_pwd=dirname)
    proc.connect('terminated', terminated)
    proc.connect('start-failed', start_failed)
    proc.connect('received-output', output)

    self.io_cb('info', _("Shutting down Docker Compose: running {} in {}").format(cmd, dirname))
    proc.Start()

class CClusterSystem(api.CClusterSystem):
  
  gStatusCBs = []
  
  def GetClusterSystemInfo(self):
    
    # XXX Hack in conjunction with setting network_mode in debug docker-compose file
    # XXX to 'host'.  This probably needs more work or a different approach, and 
    # XXX it isn't needed under WSL2.
    if sys.platform.startswith('linux') and not os.environ.get('WSL_DISTRO_NAME'):
      connect_hostname = 'localhost'
    else:
      connect_hostname = 'host.docker.internal'
      
    return {
      'id': 'docker-compose',
      'name': 'Docker Compose',
      'default-host-ip': connect_hostname,
      'config-file-spec': ('file', ['*.yml']),
    }
    
  def SupportsConfiguration(self, config_path):
    
    parse = self.ParseConfiguration(config_path)
    return bool(parse)
  
  gConfigCache = {}
  def ParseConfiguration(self, config_path):

    if not os.path.exists(config_path):
      return {}
    
    existing, modtime = self.gConfigCache.get(config_path, (None, None))
    curr_modtime = os.stat(config_path)[stat.ST_MTIME]
    if existing and curr_modtime == modtime:
      return existing
      
    retval = {}
    
    parse = self.__ParseDockerComposeFile(config_path)
    if parse is None:
      return {}
    
    for name, values in parse.get('services', {}).items():
      item = {}
      item['service'] = name
      item['container_system'] = 'docker'
      item['image'] = self.__GetServiceImage(config_path, name, values)
      item['source-image'] = self.__GetSourceImage(config_path, name, values)
      
      mappings = []
      for mapping in values.get('volumes', []):
        textutils.RemoveQuotes(mapping)
        host, container = mapping.split(':')[:2]
        # XXX Not sure if this would be better but works without it
        # if host == '.':
          # host = os.path.dirname(config_path)
        mappings.append((host, container))
      item['mappings'] = mappings
      
      item['env'] = list(values.get('environment', {}))
      
      retval[name] = item
      
    self.gConfigCache[config_path] = (retval, curr_modtime)
    
    return retval

  def GetBuildCommandLines(self, config_path):
    
    dirname = os.path.dirname(config_path)
    cmds = [
      (dirname, ['docker-compose', '-f', config_path, 'build']), 
      (dirname, ['docker-compose', '-f', config_path, 'up', '--no-start']), 
    ]
    
    return cmds
    
  def GetServicesInfo(self, config_path, services, result_cb):
    
    import singleton
    s = singleton.GetSingletons()
    
    plugin = None
    for p in s.fPluginMgr.GetPlugins('container'):
      if p.CContainerSystem().GetContainerSystemInfo()['id'] == 'docker':
        plugin = p
        break
    assert plugin is not None
    
    parse = self.ParseConfiguration(config_path)
    retval = {}
    
    for service in services:

      def got_modtime(modtime, service=service):
        retval[service] = {'modtime': modtime}
        if len(retval) == len(services):
          result_cb(retval)
    
      if service not in parse:
        got_modtime(None)
      else:
        plugin.CContainerSystem()._GetContainerImageCreationTime(parse[service]['image'],
                                                                  got_modtime)
        
  def GetServiceConfigurationLocation(self, config_path, service):
    
    if not os.path.exists(config_path):
      return None, None, None, None
    
    try:
      with open(config_path, 'r') as f:
        lines = f.readlines()
    except:
      print("Failed to read config file", config_path)
      traceback.print_exc()
      return None, None, None, None
    
    seen_services = False
    services_indent = None
    current_service = None
    pos = 0
    for i, line in enumerate(lines):

      if not seen_services:
        if line.strip().lower().startswith('services:'):
          seen_services = True
        pos += len(line)
        continue
      
      if not services_indent:
        services_indent, non_indent_col = textutils.GetIndent(line, 8)
        
      current_indent, current_indent_col = textutils.GetIndent(line, 8)
      if current_indent == services_indent:
        current_service = line[:line.find(':')].strip()
        if current_service == service:
          return config_path, i, pos + line.find(current_service), pos + line.find(current_service) + len(current_service)
        
      pos += len(line)
    
    return None, None, None, None
        
  def __GetServiceImage(self, config_path, service, values):
    """Get the built image used by the service."""
    
    if 'image' in values:
      return values['image']
    
    else:        
      path, dirname = os.path.split(os.path.dirname(config_path))
      return dirname + '_' + service
                                                              
  def __GetSourceImage(self, config_path, service, values):
    """Get the image from which the named service was built; falls back on the service image
    if it cannot be determined.  It is the same as the service image if nothing was built."""
    
    if 'image' in values:
      return values['image']
    
    elif 'build' in values:
      build = values['build']
      if isinstance(build, dict):
        dirname = build.get('context', '.')
        dockerfile = build.get('dockerfile', 'Dockerfile')
      else:
        dirname = build
        dockerfile = 'Dockerfile'
      parent_dir = os.path.dirname(config_path)
      build_dir = os.path.normpath(os.path.join(parent_dir, dirname))
      build_file = os.path.join(build_dir, dockerfile)
      try:
        with open(build_file, 'r') as f:
          lines = f.readlines()
          f.close()
      except Exception:
        print("Failed to read Dockerfile to determine image name:", build_file)
      else:
        for line in lines:
          if line.lower().startswith('from ') or line.lower().startswith('from\t'):
            parts = line.split()
            if len(parts) == 2:
              return parts[1]

    return self.__GetServiceImage(config_path, service, values)
    
  def __ParseDockerComposeFile(self, config_path):
    
    try:
      with open(config_path, 'rb') as f:
        parse = pyyaml.load(f)      
      return parse
        
    except:
      print("Failed to parse Docker Config file", config_path)
      traceback.print_exc()
      return None
    
  def __WriteDockerComposeFile(self, stream, data):
     
    try:
      pyyaml.dump(data, stream)
      return True
    except:
      print("Failed to write Docker Config File")
      print(data)
      traceback.print_exc()
      return False
    
  def _CreateDerivedConfiguration(self, config_path, env, mappings):
    """Create a derived cluster configuration based on the given configuration, in order
    to add the given environment and host->container file mappings.  The arguments are:
    
      config_path -- The full path to the existing configuration file
      envs        -- A list of (name, value) environment variables to add
      mappings    -- A dictionary from container service name to lists of
                     (host, container) file mappings
      
    The configuration is written into the same directory as config_path and the full path
    to it returned. The caller will remove the configuration when no longer needed."""
    
    parse = self.__ParseDockerComposeFile(config_path)
    
    for service, svc_env in env.items():
      
      if service not in parse['services']:
        print("Unable to add env to service {} -- does not exist".format(service))
        continue
      
      # Note that 'environment' can be either a dict or an array (list)
      existing_env = parse['services'][service].get('environment', {})
      if isinstance(existing_env, list):
        cvt_env = {}
        for item in existing_env:
          if '=' not in item:
            print("Omitting invalid environment item", service, item)
            continue
          key = item[:item.find('=')]
          value = item[item.find('=')+1:]
          cvt_env[key] = value
        existing_env = cvt_env
        
      for key, value in svc_env:
        existing_env[textutils.AsStr(key)] = textutils.AsStr(value)
      parse['services'][service]['environment'] = existing_env        
      
    for service, svc_mappings in mappings.items():
      
      if service not in parse['services']:
        print("Unable to add mappings to service {} -- does not exist".format(service))
        continue
      
      existing_volumes = parse['services'][service].get('volumes', [])
      for host, container in svc_mappings:
        existing_volumes.append('{}:{}'.format(host, container))
      parse['services'][service]['volumes'] = existing_volumes
      # XXX Hack to get networking working on Linux (together with using '127.0.0.1' as the
      # XXX Connnect Hostname instead of 'host.docker.internal') - this probably needs more work
      # XXX or a different approach, and it does not work when running under WSL2.
      if sys.platform.startswith('linux') and not os.environ.get('WSL_DISTRO_NAME'):
        parse['services'][service]['network_mode'] = 'host'

    def fdopen_yaml(fd, mode):
      """ yaml is defined as utf-16 or utf-8.  We assume it's utf-8 """
      assert 'b' not in mode
      if sys.version_info < (3, 0):
        return os.fdopen(fd, mode)
      return os.fdopen(fd, mode, encoding='utf-8')
    
    dirname, fn = os.path.split(config_path)
    fd, filename = tempfile.mkstemp('yml')
    with fdopen_yaml(fd, 'w') as f:
      if not self.__WriteDockerComposeFile(f, parse):
        return None      
    with open(filename, 'rb') as f:
      new_contents = f.read().decode('utf-8')
      
    filename = os.path.join(dirname, 'wing-debug-{}'.format(fn))
    if os.path.exists(filename):
      with open(filename, 'rb') as f:
        existing_contents = f.read().decode('utf-8')
    else:
      existing_contents = ''
    
    # Only actually write the file if its content has changed, so images are not
    # rebuilt unnecessarily
    # XXX Actually currently rebuilds anyway for unknown reason (but works on command line)
    if new_contents != existing_contents:
      if os.path.exists(filename):
        updateutils.Delete(filename, force=1)
      with open(filename, 'wb') as f:
        f.write(new_contents.encode('utf-8'))
        
    return filename

  def GetCluster(self, config_path, io_cb, create=True):
    
    if config_path in gClusters:
      return gClusters[config_path]
    
    if not create:
      return None
    
    return CCluster(config_path, io_cb)
    
  def GetRunningClusters(self):
    """Get a list of config paths for all clusters that are currently running or
    in the process of terminating."""
    
    return set([c.config_path for c in gClusters.values() if c.IsRunning() or c._terminating])

  def Shutdown(self):
    """Shut down all currently running clusters."""
    
    for c in list(gClusters.values()):
      c.Terminate()
    
  def RegisterStatusCallback(self, cb):
    """Register a callback to invoke when the runtime status of clusters change. The
    callback is invoked with (config_path, status) where status is one of the cluster
    status class attributes for CContainerInstance: kRunning, kTerminated, kStarting,
    kStartFailed, kTerminating."""
    
    self.gStatusCBs.append(cb)
    
  def RemoveStatusCallback(self, cb):
    """Remove a callback previously registered with RegisterStatusCallback()."""
    
    self.gStatusCBs.remove(cb)

  @classmethod
  def _CallStatusCallbacks(cls, config_path, status):
    
    for cb in cls.gStatusCBs:
      cb(config_path, status)
      
