#
# Copyright (c) 2018 Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA. 
#

"""
  Configuration - VDO manager configuration file handling

  $Id: //eng/vdo-releases/magnesium/src/python/vdo/vdomgmnt/Configuration.py#7 $

"""
from . import ArgumentError, MgmntLogger
from . import StateExitStatus
from . import VDOService
from utils import Command, runCommand
from utils import FileLock, YAMLObject

import errno
import os
from stat import ST_MTIME
import time
import yaml


class BadConfigurationFileError(StateExitStatus, Exception):
  """Exception raised to indicate an error in processing the
  configuration file, such as a parse error or missing data.
  """

  ######################################################################
  # Overridden methods
  ######################################################################
  def __init__(self, msg, *args, **kwargs):
    super(BadConfigurationFileError, self).__init__(*args, **kwargs)
    self._msg = msg

  ######################################################################
  def __str__(self):
    return self._msg

########################################################################
class Configuration(YAMLObject):
  """Configuration of VDO volumes and associated Albireo servers.

  This class is designed for use with the "with" statement. If
  Command.noRunMode is True, the file will still be opened and read
  but writes will not be performed.

  The Configuration is stored in a simple XML format; see
  vdoconfig.dtd.

  Attributes:
    _vdos: A dictionary of VDOServices, indexed by name.
    _filename: The name of the configuration file.
    _readonly: True iff this Configuration is read-only.
    _dirty: True iff this Configuration has been modified but the
      changes have not been persisted.
    _mustExist: If True, the file must exist (otherwise a missing
      file is treated as an empty configuration).
  """
  log = MgmntLogger.getLogger(MgmntLogger.myname + '.Configuration')
  supportedSchemaVersions = [0x20170907]
  modifiableSingltons = {}
  singletonLock = '/var/lock/vdo-config-singletons'
  yaml_tag = u"!Configuration"

  ######################################################################
  # Public methods
  ######################################################################
  @classmethod
  def modifiableSingleton(cls, filepath):
    """Allocates, as necessary, and returns a modifiable, singleton
    Configuration instance for the specified filepath.  Separate entities can
    thus share one in memory copy of the configuration file allowing for
    encapsulation of per-entity manipulation of the configuration.

    Args:
      filepath (str):   path to config file
    """
    config = None
    with FileLock(cls.singletonLock, "r+") as f:
      config = cls.modifiableSingltons.get(filepath)
      if config is None:
        config = Configuration(filepath, readonly = False)
        cls.modifiableSingltons[filepath] = config
    return config

  ######################################################################
  def addVdo(self, name, vdo, replace=False):
    """Adds or replaces a VDOService object in the configuration.
    Generates an assertion error if this object is read-only.

    Arguments:
    name -- name of the VDOService
    vdo -- the VDOService to add or replace
    replace -- if True, any existing VDOService will be replaced
    Returns: False if the VDOService exists and replace is False,
      True otherwise
    """
    self._assertCanModify()
    self.log.debug("Adding vdo \"{0}\" to configuration".format(name))
    if not replace and self.haveVdo(name):
      return False
    self._vdos[name] = vdo
    self._dirty = True
    return True

  ######################################################################
  def asYAMLForUser(self):
    """Returns the configuration's YAML representation to present to users.
    """
    return yaml.dump({ "filename" : self.filepath,
                       "config"   : self},
                      default_flow_style = False)

  ######################################################################
  @property
  def filepath(self):
    """Returns the file path of the configuration file."""
    return self._filename

  ######################################################################
  def getAllVdos(self):
    """Retrieves a list of all known VDOs."""
    return self._vdos

  ######################################################################
  def getVdo(self, name):
    """Retrieves a VDO by name."""
    vdo = None
    try:
      vdo = self._vdos[name]
    except KeyError:
      raise ArgumentError(_("VDO volume {0} not found").format(name))
    return vdo

  ######################################################################
  def haveVdo(self, name):
    """Returns True if we have a VDO with a given name."""
    return name in self._vdos

  ######################################################################
  def isDeviceConfigured(self, device):
    """Returns a boolean indicating if the configuration contains a VDO using
    the specified device.

    Both the specified device and the device from the vdos present in the
    configuration are fully resolved for the check.
    """
    device = os.path.realpath(device)
    for vdo in self._vdos:
      if device == os.path.realpath(self._vdos[vdo].device):
        return True
    return False

  ######################################################################
  def persist(self):
    """Writes out the Configuration if necessary.

    If the Configuration is read-only or has not been modified, this
    method will silently return. If Command.noRunMode is True, any
    new Configuration will be printed to stdout instead of the file.

    This method will generate an assertion failure if the configuration
    file is not open.
    """
    if self._readonly:
      return
    if not self._dirty:
      self.log.debug("Configuration is clean, not persisting")
      return

    self.log.debug("Writing configuration to {0}".format(self.filepath))

    if self._empty():
      self._removeFile()
      return

    s = yaml.dump({"config" : self}, default_flow_style = False)

    if Command.noRunMode():
      print(_("New configuration (not written):"))
      print(s)
      self._dirty = False
      return

    newFile = self.filepath + ".new"
    if os.path.exists(newFile):
      os.remove(newFile)
    with open(newFile, 'w') as fh:
      # Write the warning about not editing the file.
      fh.write(
       "####################################################################")
      fh.write(os.linesep)
      fh.write("# {0}".format(
        _("THIS FILE IS MACHINE GENERATED. DO NOT EDIT THIS FILE BY HAND.")))
      fh.write(os.linesep)
      fh.write(
       "####################################################################")
      fh.write(os.linesep)

      # Write the configuration, flush and sync.
      fh.write(s)
      fh.flush()
      os.fsync(fh)
    os.rename(newFile, self.filepath)
    self._fsyncDirectory()
    self._dirty = False

  ######################################################################
  def removeVdo(self, name):
    """Removes a VDO by name."""
    self._assertCanModify()
    del self._vdos[name]
    self._dirty = True

  ######################################################################
  def status(self):
    """Returns a dictionary representing the status of this object.
    """
    status = {}

    st = None
    try:
      st = os.stat(self.filepath)
      status[_("File")] = self.filepath
    except OSError as ex:
      if ex.errno != errno.ENOENT:
        raise
      status[_("File")] = _("does not exist")

    if st is None:
      status[_("Last modified")] = _("not available")
    else:
      status[_("Last modified")] = time.strftime('%Y-%m-%d %H:%M:%S',
                                                 time.localtime(st[ST_MTIME]))

    return status

  ######################################################################
  # Overridden methods
  ######################################################################
  @classmethod
  def _yamlMakeInstance(cls):
    return cls("/dev/YAMLInstance")

  ######################################################################
  @property
  def _yamlAttributeKeys(self):
    return ["version", "vdos"]

  ######################################################################
  @property
  def _yamlData(self):
    data = super(Configuration, self)._yamlData
    data["version"] = self._schemaVersion
    data["vdos"] = self._vdos
    return data

  ######################################################################
  def _yamlSetAttributes(self, attributes):
    super(Configuration, self)._yamlSetAttributes(attributes)
    self.version = attributes["version"]
    self.vdos = attributes["vdos"]

  ######################################################################
  @property
  def _yamlSpeciallyHandledAttributes(self):
    specials = super(Configuration, self)._yamlSpeciallyHandledAttributes
    specials.extend(["version", "vdos"])
    return specials

  ######################################################################
  def _yamlUpdateFromInstance(self, instance):
    super(Configuration, self)._yamlUpdateFromInstance(instance)

    self._schemaVersion = instance.version
    self._vdos = instance.vdos
    for vdo in self._vdos:
      self._vdos[vdo].setConfig(self)

  ######################################################################
  def __init__(self, filename, readonly=True, mustExist=False):
    """Construct a Configuration.

    Args:
      filename (str): The path to the XML configuration file

    Kwargs:
      readonly (bool): If True, the configuration is read-only.
      mustExist (bool): If True, the configuration file must exist.

    Raises:
      ArgumentError
    """
    super(Configuration, self).__init__()
    self._vdos = {}
    self._filename = filename
    self._readonly = readonly
    self._dirty = False
    self._mustExist = mustExist
    self._schemaVersion = 0x20170907
    if self._mustExist and not os.path.exists(self.filepath):
      raise ArgumentError(_("Configuration file {0} does not exist.").format(
          self.filepath))
    mode = 'r' if readonly else 'a+'
    try:
      if os.path.exists(filename):
        if os.path.getsize(filename) != 0:
          with open(filename, mode) as fh:
            self._read(fh)
    except IOError as msg:
      raise ArgumentError(str(msg))

  ######################################################################
  def __str__(self):
    return "{0}({1})".format(type(self).__name__, self.filepath)

  ######################################################################
  # Protected methods
  ######################################################################
  def _assertCanModify(self):
    """Asserts that mutative operations are allowed on this object."""
    assert not self._readonly, "Configuration is read-only"

  ######################################################################
  def _empty(self):
    """Returns True if this configuration is empty."""
    return len(self._vdos) == 0

  ######################################################################
  def _fsyncDirectory(self):
    """Open and issue an fsync on the directory containing the config file.
    """
    dirname = os.path.dirname(self.filepath)
    if Command.noRunMode():
      runCommand(['fsync', dirname])
      return
    fd = os.open(dirname, os.O_RDONLY)
    try:
      os.fsync(fd)
    finally:
      os.close(fd)

  ######################################################################
  def _read(self, fh):
    """Reads in a Configuration from a file."""
    self.log.debug("Reading configuration from {0}".format(self.filepath))
    try:
      conf = yaml.safe_load(fh)
    except yaml.scanner.ScannerError:
      raise BadConfigurationFileError(_("Not a valid configuration file"))

    # Because we do indirection instantiation from the YAML load we need to
    # call _yamlUpdateFromInstance().
    try:
      config = conf["config"]
    except KeyError:
      raise BadConfigurationFileError(_("Not a valid configuration file"
                                        " (missing 'config' section)"))
    except Exception as ex:
      self.log.debug("Not a valid configuration file: {0}".format(ex))
      raise BadConfigurationFileError(_("Not a valid configuration file"))

    try:
      self._yamlUpdateFromInstance(config)
    except Exception as ex:
      self.log.debug("Not a valid configuration file: {0}".format(ex))
      raise BadConfigurationFileError(_("Not a valid configuration file"))

    self._dirty = False
    return 0

  ######################################################################
  def _removeFile(self):
    """Deletes the current configuration file.
    In noRun mode, pretend that we're doing an rm of the file."""
    if Command.noRunMode():
      runCommand(['rm', self.filepath])
      return

    if os.path.exists(self.filepath):
      os.remove(self.filepath)
      self._fsyncDirectory()

    try:
      with FileLock(self.singletonLock, "r+") as f:
        del Configuration.modifiableSingltons[self.filepath]
    except KeyError:
      pass

  ######################################################################
  @classmethod
  def _validateVersion(cls, ver):
    """Checks a configuration file schema version string against the list
    of supported schemas.

    Args:
      ver (str): the schema version string to check

    Raises:
      BadConfigurationFileError: version not supported.
    """
    if ver not in cls.supportedSchemaVersions:
      raise BadConfigurationFileError(_(
          "Configuration file version {v} not supported").format(v=ver))
