# Copyright 2015 Tesora Inc.
#
#    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 enum
import hashlib
import os
from pathlib import Path
from requests.exceptions import ConnectionError

from oslo_log import log as logging
from swiftclient.client import ClientException

from trove.common import cfg
from trove.common import clients
from trove.common import exception
from trove.common.i18n import _
from trove.common import stream_codecs
from trove.common import timeutils
from trove.guestagent.common import operating_system
from trove.guestagent.common.operating_system import FileMode


LOG = logging.getLogger(__name__)
CONF = cfg.CONF


class LogType(enum.Enum):
    """Represent the type of the log object."""

    # System logs.  These are always enabled.
    SYS = 1

    # User logs.  These can be enabled or disabled.
    USER = 2


class LogStatus(enum.Enum):
    """Represent the status of the log object."""

    # The log is disabled and potentially no data is being written to
    # the corresponding log file
    Disabled = 1

    # Logging is on, but no determination has been made about data availability
    Enabled = 2

    # Logging is on, but no log data is available to publish
    Unavailable = 3

    # Logging is on and data is available to be published
    Ready = 4

    # Logging is on and all data has been published
    Published = 5

    # Logging is on and some data has been published
    Partial = 6

    # Log file has been rotated, so next publish will discard log first
    Rotated = 7

    # Waiting for a datastore restart to begin logging
    Restart_Required = 8

    # Now that restart has completed, regular status can be reported again
    # This is an internal status
    Restart_Completed = 9


class GuestLog(object):

    MF_FILE_SUFFIX = '_metafile'
    MF_LABEL_LOG_NAME = 'log_name'
    MF_LABEL_LOG_TYPE = 'log_type'
    MF_LABEL_LOG_FILE = 'log_file'
    MF_LABEL_LOG_SIZE = 'log_size'
    MF_LABEL_LOG_HEADER = 'log_header_digest'

    def __init__(self, log_context, log_name, log_type, log_user, log_file,
                 log_exposed):
        self._context = log_context
        self._name = log_name
        self._type = log_type
        self._user = log_user
        self._file = log_file
        self._exposed = log_exposed
        self._size = None
        self._published_size = None
        self._header_digest = 'abc'
        self._published_header_digest = None
        self._status = None
        self._cached_context = None
        self._cached_swift_client = None
        self._enabled = log_type == LogType.SYS
        self._file_readable = False
        self._container_name = None
        self._codec = stream_codecs.JsonCodec()

        self._set_status(self._type == LogType.USER,
                         LogStatus.Disabled, LogStatus.Enabled)

        # The directory should already exist - make sure we have access to it
        log_dir = os.path.dirname(self._file)
        operating_system.chmod(
            log_dir, FileMode.ADD_GRP_RX_OTH_RX, as_root=True)

    @property
    def context(self):
        return self._context

    @context.setter
    def context(self, context):
        self._context = context

    @property
    def type(self):
        return self._type

    @property
    def swift_client(self):
        if not self._cached_swift_client or (
                self._cached_context != self.context):
            self._cached_swift_client = clients.swift_client(self.context)
            self._cached_context = self.context
        return self._cached_swift_client

    @property
    def exposed(self):
        return self._exposed or self.context.is_admin

    @property
    def enabled(self):
        return self._enabled

    @enabled.setter
    def enabled(self, enabled):
        self._enabled = enabled

    @property
    def status(self):
        return self._status

    @status.setter
    def status(self, status):
        # Keep the status in Restart_Required until we're set
        # to Restart_Completed
        if (self.status != LogStatus.Restart_Required or
                (self.status == LogStatus.Restart_Required and
                 status == LogStatus.Restart_Completed)):
            self._status = status
            LOG.debug("Log status for '%(name)s' set to %(status)s",
                      {'name': self._name, 'status': status})
        else:
            LOG.debug("Log status for '%(name)s' *not* set to %(status)s "
                      "(currently %(current_status)s)",
                      {'name': self._name, 'status': status,
                       'current_status': self.status})

    def get_container_name(self, force=False):
        if not self._container_name or force:
            container_name = CONF.guest_log_container_name
            try:
                self.swift_client.get_container(container_name, prefix='dummy')
            except ClientException as ex:
                if ex.http_status == 404:
                    LOG.debug("Container '%s' not found; creating now",
                              container_name)
                    self.swift_client.put_container(
                        container_name, headers=self._get_headers())
                else:
                    LOG.exception("Could not retrieve container '%s'",
                                  container_name)
                    raise
            self._container_name = container_name
        return self._container_name

    def _set_status(self, use_first, first_status, second_status):
        if use_first:
            self.status = first_status
        else:
            self.status = second_status

    def show(self):
        if self.exposed:
            self._refresh_details()
            container_name = 'None'
            prefix = 'None'
            if self._published_size:
                container_name = self.get_container_name()
                prefix = self._object_prefix()
            pending = self._size - self._published_size
            if self.status == LogStatus.Rotated:
                pending = self._size
            return {
                'name': self._name,
                'type': self._type.name,
                'status': self.status.name.replace('_', ' '),
                'published': self._published_size,
                'pending': pending,
                'container': container_name,
                'prefix': prefix,
                'metafile': self._metafile_name()
            }
        else:
            raise exception.LogAccessForbidden(action='show', log=self._name)

    def _refresh_details(self):
        if self._published_size is None:
            # Initializing, so get all the values
            try:
                meta_details = self._get_meta_details()
                self._published_size = int(
                    meta_details[self.MF_LABEL_LOG_SIZE])
                self._published_header_digest = (
                    meta_details[self.MF_LABEL_LOG_HEADER])
            except ClientException as ex:
                if ex.http_status == 404:
                    LOG.debug("No published metadata found for log '%s'",
                              self._name)
                    self._published_size = 0
                else:
                    LOG.exception("Could not get meta details for log '%s'",
                                  self._name)
                    raise
            except ConnectionError as e:
                # A bad endpoint will cause a ConnectionError
                # This exception contains another exception that we want
                exc = e.args[0]
                raise exc

        self._update_details()
        LOG.debug("Log size for '%(name)s' set to %(size)d "
                  "(published %(published)d)",
                  {'name': self._name, 'size': self._size,
                   'published': self._published_size})

    def _update_details(self):
        if operating_system.exists(self._file, as_root=True):
            file_path = Path(self._file)

            # Make sure guest agent can read the log file.
            if not os.access(self._file, os.R_OK):
                operating_system.chmod(self._file, FileMode.ADD_ALL_R,
                                       as_root=True)
                operating_system.chmod(str(file_path.parent),
                                       FileMode.ADD_GRP_RX_OTH_RX,
                                       as_root=True)

            self._size = file_path.stat().st_size
            self._update_log_header_digest(self._file)

            if self.status != LogStatus.Disabled:
                if self._log_rotated():
                    self.status = LogStatus.Rotated
                # See if we have stuff to publish
                elif self._size > self._published_size:
                    self._set_status(self._published_size,
                                     LogStatus.Partial, LogStatus.Ready)
                # We've published everything so far
                elif self._size == self._published_size:
                    self._set_status(self._published_size,
                                     LogStatus.Published, LogStatus.Enabled)
                # We've already handled this case (log rotated) so what gives?
                else:
                    raise Exception(_("Bug in _log_rotated ?"))
        else:
            LOG.warning(f"File {self._file} does not exist")
            self._published_size = 0
            self._size = 0

    def _log_rotated(self):
        """If the file is smaller than the last reported size
        or the first line hash is different, we can probably assume
        the file changed under our nose.
        """
        if (self._published_size > 0 and
                (self._size < self._published_size or
                 self._published_header_digest != self._header_digest)):
            return True

    def _update_log_header_digest(self, log_file):
        with open(log_file, 'rb') as log:
            self._header_digest = hashlib.md5(log.readline()).hexdigest()

    def _get_headers(self):
        return {'X-Delete-After': str(CONF.guest_log_expiry)}

    def publish_log(self):
        if self.exposed:
            if self._log_rotated():
                LOG.debug("Log file rotation detected for '%s' - "
                          "discarding old log", self._name)
                self._delete_log_components()
            if operating_system.exists(self._file, as_root=True):
                self._publish_to_container(self._file)
            else:
                raise RuntimeError(_(
                    "Cannot publish log file '%s' as it does not exist.") %
                    self._file)
            return self.show()
        else:
            raise exception.LogAccessForbidden(
                action='publish', log=self._name)

    def discard_log(self):
        if self.exposed:
            self._delete_log_components()
            return self.show()
        else:
            raise exception.LogAccessForbidden(
                action='discard', log=self._name)

    def _delete_log_components(self):
        container_name = self.get_container_name(force=True)
        prefix = self._object_prefix()
        swift_files = [swift_file['name']
                       for swift_file in self.swift_client.get_container(
                       container_name, prefix=prefix)[1]]
        swift_files.append(self._metafile_name())
        for swift_file in swift_files:
            self.swift_client.delete_object(container_name, swift_file)
        self._published_size = 0

    def _publish_to_container(self, log_filename):
        log_component, log_lines = '', 0
        chunk_size = CONF.guest_log_limit
        container_name = self.get_container_name(force=True)

        def _read_chunk(f):
            while True:
                current_chunk = f.read(chunk_size)
                if not current_chunk:
                    break
                yield current_chunk

        def _write_log_component():
            object_headers.update({'x-object-meta-lines': str(log_lines)})
            component_name = '%s%s' % (self._object_prefix(),
                                       self._object_name())
            self.swift_client.put_object(container_name,
                                         component_name, log_component,
                                         headers=object_headers)
            self._published_size = (
                self._published_size + len(log_component))
            self._published_header_digest = self._header_digest

        self._refresh_details()
        self._put_meta_details()
        object_headers = self._get_headers()
        with open(log_filename, 'r') as log:
            LOG.debug("seeking to %s", self._published_size)
            log.seek(self._published_size)
            for chunk in _read_chunk(log):
                for log_line in chunk.splitlines():
                    if len(log_component) + len(log_line) > chunk_size:
                        _write_log_component()
                        log_component, log_lines = '', 0
                    log_component = log_component + log_line + '\n'
                    log_lines += 1
        if log_lines > 0:
            _write_log_component()
        self._put_meta_details()

    def _put_meta_details(self):
        metafile_name = self._metafile_name()
        metafile_details = {
            self.MF_LABEL_LOG_NAME: self._name,
            self.MF_LABEL_LOG_TYPE: self._type.name,
            self.MF_LABEL_LOG_FILE: self._file,
            self.MF_LABEL_LOG_SIZE: self._published_size,
            self.MF_LABEL_LOG_HEADER: self._header_digest,
        }
        container_name = self.get_container_name()
        self.swift_client.put_object(container_name, metafile_name,
                                     self._codec.serialize(metafile_details),
                                     headers=self._get_headers())
        LOG.debug("_put_meta_details has published log size as %s",
                  self._published_size)

    def _metafile_name(self):
        return self._object_prefix().rstrip('/') + '_metafile'

    def _object_prefix(self):
        return '%(instance_id)s/%(datastore)s-%(log)s/' % {
            'instance_id': CONF.guest_id,
            'datastore': CONF.datastore_manager,
            'log': self._name}

    def _object_name(self):
        return 'log-%s' % str(timeutils.utcnow()).replace(' ', 'T')

    def _get_meta_details(self):
        LOG.debug("Getting meta details for '%s'", self._name)
        metafile_name = self._metafile_name()
        container_name = self.get_container_name()
        headers, metafile_details = self.swift_client.get_object(
            container_name, metafile_name)
        LOG.debug("Found meta details for '%s'", self._name)
        return self._codec.deserialize(metafile_details)
