# Copyright (c) 2014 Red Hat, Inc.
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import io
import os
import pipes
import re
import sys

from oslo_log import log
from oslo_serialization import jsonutils
from oslo_utils import importutils

from manila import exception
from manila.i18n import _
from manila.share.drivers.ganesha import utils as ganesha_utils
from manila import utils

LOG = log.getLogger(__name__)
IWIDTH = 4


def _conf2json(conf):
    """Convert Ganesha config to JSON."""

    # tokenize config string
    token_list = [io.StringIO()]
    state = {
        'in_quote': False,
        'in_comment': False,
        'escape': False,
    }

    cbk = []
    for char in conf:
        if state['in_quote']:
            if not state['escape']:
                if char == '"':
                    state['in_quote'] = False
                    cbk.append(lambda: token_list.append(io.StringIO()))
                elif char == '\\':
                    cbk.append(lambda: state.update({'escape': True}))
        else:
            if char == "#":
                state['in_comment'] = True
            if state['in_comment']:
                if char == "\n":
                    state['in_comment'] = False
            else:
                if char == '"':
                    token_list.append(io.StringIO())
                    state['in_quote'] = True
        state['escape'] = False
        if not state['in_comment']:
            token_list[-1].write(char)
        while cbk:
            cbk.pop(0)()

    if state['in_quote']:
        raise RuntimeError("Unterminated quoted string")

    # jsonify tokens
    js_token_list = ["{"]
    for tok in token_list:
        tok = tok.getvalue()

        if tok[0] == '"':
            js_token_list.append(tok)
            continue

        for pat, s in [
                # add omitted "=" signs to block openings
                (r'([^=\s])\s*{', '\\1={'),
                # delete trailing semicolons in blocks
                (r';\s*}', '}'),
                # add omitted semicolons after blocks
                (r'}\s*([^}\s])', '};\\1'),
                # separate syntactically significant characters
                (r'([;{}=])', ' \\1 ')]:
            tok = re.sub(pat, s, tok)

        # map tokens to JSON equivalents
        for word in tok.split():
            if word == "=":
                word = ":"
            elif word == ";":
                word = ','
            elif (word in ['{', '}'] or
                  re.search(r'\A-?[1-9]\d*(\.\d+)?\Z', word)):
                pass
            else:
                word = jsonutils.dumps(word)
            js_token_list.append(word)
    js_token_list.append("}")

    # group quoted strings
    token_grp_list = []
    for tok in js_token_list:
        if tok[0] == '"':
            if not (token_grp_list and isinstance(token_grp_list[-1], list)):
                token_grp_list.append([])
            token_grp_list[-1].append(tok)
        else:
            token_grp_list.append(tok)

    # process quoted string groups by joining them
    js_token_list2 = []
    for x in token_grp_list:
        if isinstance(x, list):
            x = ''.join(['"'] + [tok[1:-1] for tok in x] + ['"'])
        js_token_list2.append(x)

    return ''.join(js_token_list2)


def _dump_to_conf(confdict, out=sys.stdout, indent=0):
    """Output confdict in Ganesha config format."""
    if isinstance(confdict, dict):
        for k, v in confdict.items():
            if v is None:
                continue
            if isinstance(v, dict):
                out.write(' ' * (indent * IWIDTH) + k + ' ')
                out.write("{\n")
                _dump_to_conf(v, out, indent + 1)
                out.write(' ' * (indent * IWIDTH) + '}')
            elif isinstance(v, list):
                for item in v:
                    out.write(' ' * (indent * IWIDTH) + k + ' ')
                    out.write("{\n")
                    _dump_to_conf(item, out, indent + 1)
                    out.write(' ' * (indent * IWIDTH) + '}\n')
            # The 'CLIENTS' Ganesha string option is an exception in that it's
            # string value can't be enclosed within quotes as can be done for
            # other string options in a valid Ganesha conf file.
            elif k.upper() == 'CLIENTS':
                out.write(' ' * (indent * IWIDTH) + k + ' = ' + v + ';')
            else:
                out.write(' ' * (indent * IWIDTH) + k + ' ')
                out.write('= ')
                _dump_to_conf(v, out, indent)
                out.write(';')
            out.write('\n')
    else:
        dj = jsonutils.dumps(confdict)
        out.write(dj)


def parseconf(conf):
    """Parse Ganesha config.

    Both native format and JSON are supported.

    Convert config to a (nested) dictionary.
    """
    def list_to_dict(src_list):
        # Convert a list of key-value pairs stored as tuples to a dict.
        # For tuples with identical keys, preserve all the values in a
        # list. e.g., argument [('k', 'v1'), ('k', 'v2')] to function
        # returns {'k': ['v1', 'v2']}.
        dst_dict = {}
        for i in src_list:
            if isinstance(i, tuple):
                k, v = i
                if isinstance(v, list):
                    v = list_to_dict(v)
                if k in dst_dict:
                    dst_dict[k] = [dst_dict[k]]
                    dst_dict[k].append(v)
                else:
                    dst_dict[k] = v
        return dst_dict

    try:
        # allow config to be specified in JSON --
        # for sake of people who might feel Ganesha config foreign.
        d = jsonutils.loads(conf)
    except ValueError:
        # Customize JSON decoder to convert Ganesha config to a list
        # of key-value pairs stored as tuples. This allows multiple
        # occurrences of a config block to be later converted to a
        # dict key-value pair, with block name being the key and a
        # list of block contents being the value.
        li = jsonutils.loads(_conf2json(conf), object_pairs_hook=lambda x: x)
        d = list_to_dict(li)
    return d


def mkconf(confdict):
    """Create Ganesha config string from confdict."""
    s = io.StringIO()
    _dump_to_conf(confdict, s)
    return s.getvalue()


rados = None


def setup_rados():
    global rados
    if not rados:
        try:
            rados = importutils.import_module('rados')
        except ImportError:
            raise exception.ShareBackendException(
                _("rados python module is not installed"))


class GaneshaManager(object):
    """Ganesha instrumentation class."""

    def __init__(self, execute, tag, **kwargs):
        self.confrx = re.compile(r'\.conf\Z')
        self.ganesha_config_path = kwargs['ganesha_config_path']
        self.tag = tag

        def _execute(*args, **kwargs):
            msg = kwargs.pop('message', args[0])
            makelog = kwargs.pop('makelog', True)
            try:
                return execute(*args, **kwargs)
            except exception.ProcessExecutionError as e:
                if makelog:
                    LOG.error(
                        ("Error while executing management command on "
                         "Ganesha node %(tag)s: %(msg)s."),
                        {'tag': tag, 'msg': msg})
                raise exception.GaneshaCommandFailure(
                    stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code,
                    cmd=e.cmd)
        self.execute = _execute
        self.ganesha_service = kwargs['ganesha_service_name']
        self.ganesha_export_dir = kwargs['ganesha_export_dir']
        self.execute('mkdir', '-p', self.ganesha_export_dir)

        self.ganesha_rados_store_enable = kwargs.get(
            'ganesha_rados_store_enable')
        if self.ganesha_rados_store_enable:
            setup_rados()
            self.ganesha_rados_store_pool_name = (
                kwargs['ganesha_rados_store_pool_name'])
            self.ganesha_rados_export_counter = (
                kwargs['ganesha_rados_export_counter'])
            self.ganesha_rados_export_index = (
                kwargs['ganesha_rados_export_index'])
            self.rados_client = kwargs['rados_client']
            try:
                self._get_rados_object(self.ganesha_rados_export_counter)
            except rados.ObjectNotFound:
                self._put_rados_object(self.ganesha_rados_export_counter,
                                       str(1000))
        else:
            self.ganesha_db_path = kwargs['ganesha_db_path']
            self.execute('mkdir', '-p', os.path.dirname(self.ganesha_db_path))
            # Here we are to make sure that an SQLite database of the
            # required scheme exists at self.ganesha_db_path.
            # The following command gets us there -- provided the file
            # does not yet exist (otherwise it just fails). However,
            # we don't care about this condition, we just execute the
            # command unconditionally (ignoring failure). Instead we
            # directly query the db right after, to check its validity.
            self.execute(
                "sqlite3", self.ganesha_db_path,
                'create table ganesha(key varchar(20) primary key, '
                'value int); insert into ganesha values("exportid", '
                '100);', run_as_root=False, check_exit_code=False)
            self.get_export_id(bump=False)

    def _getpath(self, name):
        """Get the path of config file for name."""
        return os.path.join(self.ganesha_export_dir, name + ".conf")

    @staticmethod
    def _get_export_rados_object_name(name):
        return 'ganesha-export-' + name

    def _write_tmp_conf_file(self, path, data):
        """Write data to tmp conf file."""
        dirpath, fname = (getattr(os.path, q + "name")(path) for q in
                          ("dir", "base"))
        tmpf = self.execute('mktemp', '-p', dirpath, "-t",
                            fname + ".XXXXXX")[0][:-1]
        self.execute(
            'sh', '-c',
            'echo %s > %s' % (pipes.quote(data), pipes.quote(tmpf)),
            message='writing ' + tmpf)
        return tmpf

    def _write_conf_file(self, name, data):
        """Write data to config file for name atomically."""
        path = self._getpath(name)
        tmpf = self._write_tmp_conf_file(path, data)
        try:
            self.execute('mv', tmpf, path)
        except exception.ProcessExecutionError as e:
            LOG.error('mv temp file ({0}) to {1} failed.'.format(tmpf, path))
            self.execute('rm', tmpf)
            raise exception.GaneshaCommandFailure(
                stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code,
                cmd=e.cmd)

        return path

    def _mkindex(self):
        """Generate the index file for current exports."""
        @utils.synchronized("ganesha-index-" + self.tag, external=True)
        def _mkindex():
            files = filter(lambda f: self.confrx.search(f) and
                           f != "INDEX.conf",
                           self.execute('ls', self.ganesha_export_dir,
                                        run_as_root=False)[0].split("\n"))
            index = "".join(map(lambda f: "%include " + os.path.join(
                    self.ganesha_export_dir, f) + "\n", files))
            self._write_conf_file("INDEX", index)
        _mkindex()

    def _read_export_rados_object(self, name):
        return parseconf(self._get_rados_object(
            self._get_export_rados_object_name(name)))

    def _read_export_file(self, name):
        return parseconf(self.execute("cat", self._getpath(name),
                                      message='reading export ' + name)[0])

    def _read_export(self, name):
        """Return the dict of the export identified by name."""
        if self.ganesha_rados_store_enable:
            return self._read_export_rados_object(name)
        else:
            return self._read_export_file(name)

    def _check_export_rados_object_exists(self, name):
        try:
            self._get_rados_object(
                self._get_export_rados_object_name(name))
            return True
        except rados.ObjectNotFound:
            return False

    def _check_file_exists(self, path):
        try:
            self.execute('test', '-f', path, makelog=False,
                         run_as_root=False)
            return True
        except exception.GaneshaCommandFailure as e:
            if e.exit_code == 1:
                return False
            else:
                raise exception.GaneshaCommandFailure(
                    stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code,
                    cmd=e.cmd)

    def _check_export_file_exists(self, name):
        return self._check_file_exists(self._getpath(name))

    def check_export_exists(self, name):
        """Check whether export exists."""
        if self.ganesha_rados_store_enable:
            return self._check_export_rados_object_exists(name)
        else:
            return self._check_export_file_exists(name)

    def _write_export_rados_object(self, name, data):
        """Write confdict to the export RADOS object of name."""
        self._put_rados_object(self._get_export_rados_object_name(name),
                               data)
        # temp export config file required for DBus calls
        return self._write_tmp_conf_file(self._getpath(name), data)

    def _write_export(self, name, confdict):
        """Write confdict to the export file or RADOS object of name."""
        for k, v in ganesha_utils.walk(confdict):
            # values in the export block template that need to be
            # filled in by Manila are pre-fixed by '@'
            if isinstance(v, str) and v[0] == '@':
                msg = _("Incomplete export block: value %(val)s of attribute "
                        "%(key)s is a stub.") % {'key': k, 'val': v}
                raise exception.InvalidParameterValue(err=msg)
        if self.ganesha_rados_store_enable:
            return self._write_export_rados_object(name, mkconf(confdict))
        else:
            return self._write_conf_file(name, mkconf(confdict))

    def _rm_file(self, path):
        self.execute("rm", "-f", path)

    def _rm_export_file(self, name):
        """Remove export file of name."""
        self._rm_file(self._getpath(name))

    def _rm_export_rados_object(self, name):
        """Remove export object of name."""
        self._delete_rados_object(self._get_export_rados_object_name(name))

    def _dbus_send_ganesha(self, method, *args, **kwargs):
        """Send a message to Ganesha via dbus."""
        service = kwargs.pop("service", "exportmgr")
        self.execute("dbus-send", "--print-reply", "--system",
                     "--dest=org.ganesha.nfsd", "/org/ganesha/nfsd/ExportMgr",
                     "org.ganesha.nfsd.%s.%s" % (service, method), *args,
                     message='dbus call %s.%s' % (service, method), **kwargs)

    def _remove_export_dbus(self, xid):
        """Remove an export from Ganesha runtime with given export id."""
        self._dbus_send_ganesha("RemoveExport", "uint16:%d" % xid)

    def _add_rados_object_url_to_index(self, name):
        """Add an export RADOS object's URL to the RADOS URL index."""

        # TODO(rraja): Ensure that the export index object's update is atomic,
        # e.g., retry object update until the object version between the 'get'
        # and 'put' operations remains the same.
        index_data = self._get_rados_object(self.ganesha_rados_export_index)

        want_url = "%url rados://{0}/{1}".format(
            self.ganesha_rados_store_pool_name,
            self._get_export_rados_object_name(name))

        if index_data:
            self._put_rados_object(
                self.ganesha_rados_export_index,
                '\n'.join([index_data, want_url])
            )
        else:
            self._put_rados_object(self.ganesha_rados_export_index, want_url)

    def _remove_rados_object_url_from_index(self, name):
        """Remove an export RADOS object's URL from the RADOS URL index."""

        # TODO(rraja): Ensure that the export index object's update is atomic,
        # e.g., retry object update until the object version between the 'get'
        # and 'put' operations remains the same.
        index_data = self._get_rados_object(self.ganesha_rados_export_index)
        if not index_data:
            return

        unwanted_url = "%url rados://{0}/{1}".format(
            self.ganesha_rados_store_pool_name,
            self._get_export_rados_object_name(name))

        rados_urls = index_data.split('\n')
        new_rados_urls = [url for url in rados_urls if url != unwanted_url]

        self._put_rados_object(self.ganesha_rados_export_index,
                               '\n'.join(new_rados_urls))

    def add_export(self, name, confdict):
        """Add an export to Ganesha specified by confdict."""
        xid = confdict["EXPORT"]["Export_Id"]
        undos = []
        _mkindex_called = False
        try:
            path = self._write_export(name, confdict)
            if self.ganesha_rados_store_enable:
                undos.append(lambda: self._rm_export_rados_object(name))
                undos.append(lambda: self._rm_file(path))
            else:
                undos.append(lambda: self._rm_export_file(name))

            self._dbus_send_ganesha("AddExport", "string:" + path,
                                    "string:EXPORT(Export_Id=%d)" % xid)
            undos.append(lambda: self._remove_export_dbus(xid))

            if self.ganesha_rados_store_enable:
                # Clean up temp export file used for the DBus call
                self._rm_file(path)
                self._add_rados_object_url_to_index(name)
            else:
                _mkindex_called = True
                self._mkindex()
        except exception.ProcessExecutionError as e:
            for u in undos:
                u()
            if not self.ganesha_rados_store_enable and not _mkindex_called:
                self._mkindex()
            raise exception.GaneshaCommandFailure(
                stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code,
                cmd=e.cmd)

    def update_export(self, name, confdict):
        """Update an export to Ganesha specified by confdict."""
        xid = confdict["EXPORT"]["Export_Id"]
        old_confdict = self._read_export(name)

        path = self._write_export(name, confdict)
        try:
            self._dbus_send_ganesha("UpdateExport", "string:" + path,
                                    "string:EXPORT(Export_Id=%d)" % xid)
        except exception.ProcessExecutionError as e:
            # Revert the export update.
            self._write_export(name, old_confdict)
            raise exception.GaneshaCommandFailure(
                stdout=e.stdout, stderr=e.stderr, exit_code=e.exit_code,
                cmd=e.cmd)
        finally:
            if self.ganesha_rados_store_enable:
                # Clean up temp export file used for the DBus update call
                self._rm_file(path)

    def remove_export(self, name):
        """Remove an export from Ganesha."""
        try:
            confdict = self._read_export(name)
            self._remove_export_dbus(confdict["EXPORT"]["Export_Id"])
        finally:
            if self.ganesha_rados_store_enable:
                self._delete_rados_object(
                    self._get_export_rados_object_name(name))
                self._remove_rados_object_url_from_index(name)
            else:
                self._rm_export_file(name)
                self._mkindex()

    def _get_rados_object(self, object_name):
        """Synchronously read data from Ceph RADOS object as a text string.

        :param pool_name: name of the pool
        :type pool_name: str
        :param object_name: name of the object
        :type object_name: str
        :returns: tuple of object data and version
        """

        pool_name = self.ganesha_rados_store_pool_name

        ioctx = self.rados_client.open_ioctx(pool_name)

        osd_max_write_size = self.rados_client.conf_get('osd_max_write_size')
        max_size = int(osd_max_write_size) * 1024 * 1024
        try:
            bytes_read = ioctx.read(object_name, max_size)
            if ((len(bytes_read) == max_size) and
                    (ioctx.read(object_name, 1, offset=max_size))):
                LOG.warning("Size of object {0} exceeds '{1}' bytes "
                            "read".format(object_name, max_size))
        finally:
            ioctx.close()

        bytes_read_decoded = bytes_read.decode('utf-8')

        return bytes_read_decoded

    def _put_rados_object(self, object_name, data):
        """Synchronously write data as a byte string in a Ceph RADOS object.

        :param pool_name: name of the pool
        :type pool_name: str
        :param object_name: name of the object
        :type object_name: str
        :param data: data to write
        :type data: bytes
        """

        pool_name = self.ganesha_rados_store_pool_name
        encoded_data = data.encode('utf-8')

        ioctx = self.rados_client.open_ioctx(pool_name)

        max_size = int(
            self.rados_client.conf_get('osd_max_write_size')) * 1024 * 1024
        if len(encoded_data) > max_size:
            msg = ("Data to be written to object '{0}' exceeds "
                   "{1} bytes".format(object_name, max_size))
            LOG.error(msg)
            raise exception.ShareBackendException(msg)

        try:
            with rados.WriteOpCtx() as wop:
                wop.write_full(encoded_data)
                ioctx.operate_write_op(wop, object_name)
        except rados.OSError as e:
            LOG.error(e)
            raise e
        finally:
            ioctx.close()

    def _delete_rados_object(self, object_name):
        pool_name = self.ganesha_rados_store_pool_name
        ioctx = self.rados_client.open_ioctx(pool_name)
        try:
            ioctx.remove_object(object_name)
        except rados.ObjectNotFound:
            LOG.warning("Object '{0}' was already removed".format(object_name))
        finally:
            ioctx.close()

    def get_export_id(self, bump=True):
        """Get a new export id."""
        # XXX overflowing the export id (16 bit unsigned integer)
        # is not handled
        if self.ganesha_rados_store_enable:
            # TODO(rraja): Ensure that the export counter object's update is
            # atomic, e.g., retry object update until the object version
            # between the 'get' and 'put' operations remains the same.
            export_id = int(
                self._get_rados_object(self.ganesha_rados_export_counter))
            if not bump:
                return export_id
            export_id += 1
            self._put_rados_object(self.ganesha_rados_export_counter,
                                   str(export_id))
            return export_id
        else:
            if bump:
                bumpcode = 'update ganesha set value = value + 1;'
            else:
                bumpcode = ''
            out = self.execute(
                "sqlite3", self.ganesha_db_path,
                bumpcode + 'select * from ganesha where key = "exportid";',
                run_as_root=False)[0]
            match = re.search(r'\Aexportid\|(\d+)$', out)
            if not match:
                LOG.error("Invalid export database on "
                          "Ganesha node %(tag)s: %(db)s.",
                          {'tag': self.tag, 'db': self.ganesha_db_path})
                raise exception.InvalidSqliteDB()
            return int(match.groups()[0])

    def restart_service(self):
        """Restart the Ganesha service."""
        self.execute("service", self.ganesha_service, "restart")

    def reset_exports(self):
        """Delete all export files."""
        self.execute('sh', '-c',
                     'rm -f %s/*.conf' % pipes.quote(self.ganesha_export_dir))
        self._mkindex()
