# -*- coding: utf-8 -*- #
# Copyright 2020 Google LLC. 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.
"""A library for logs tailing."""


import collections
import contextlib
import datetime

# pylint: disable=unused-import, type imports needed for gRPC
import google.appengine.logging.v1.request_log_pb2
import google.appengine.v1.audit_data_pb2
import google.appengine.v1beta.audit_data_pb2
import google.cloud.appengine_v1alpha.proto.audit_data_pb2
import google.cloud.audit.audit_log_pb2
import google.cloud.bigquery.logging.v1.audit_data_pb2
import google.iam.admin.v1.audit_data_pb2
import google.iam.v1.logging.audit_data_pb2
import google.type.money_pb2
# pylint: enable=unused-import

from googlecloudsdk.api_lib.util import apis
from googlecloudsdk.core import gapic_util
from googlecloudsdk.core import log

import grpc


_SUPPRESSION_INFO_FLUSH_PERIOD_SECONDS = 2

_HELP_PAGE_LINK = 'https://cloud.google.com/logging/docs/reference/tools/gcloud-logging#tailing.'


def _HandleGrpcRendezvous(rendezvous, output_debug, output_warning):
  """Handles _MultiThreadedRendezvous errors."""
  error_messages_by_code = {
      grpc.StatusCode.INVALID_ARGUMENT:
          'Invalid argument.',
      grpc.StatusCode.RESOURCE_EXHAUSTED:
          'There are too many tail sessions open.',
      grpc.StatusCode.INTERNAL:
          'Internal error.',
      grpc.StatusCode.PERMISSION_DENIED:
          'Access is denied or has changed for resource.',
      grpc.StatusCode.OUT_OF_RANGE:
          ('The maximum duration for tail has been met. '
           'The command may be repeated to continue.')
  }

  # grpc calls cancelled by application should not warn
  if rendezvous.code() == grpc.StatusCode.CANCELLED:
    return

  output_debug(rendezvous)
  output_warning('{} ({})'.format(
      error_messages_by_code.get(rendezvous.code(),
                                 'Unknown error encountered.'),
      rendezvous.details()))


def _HandleSuppressionCounts(counts_by_reason, handler):
  """Handles supression counts."""
  client_class = apis.GetGapicClientClass('logging', 'v2')
  suppression_info = (client_class.types.TailLogEntriesResponse.SuppressionInfo)

  suppression_reason_strings = {
      suppression_info.Reason.RATE_LIMIT:
          'Logging API backend rate limit',
      suppression_info.Reason.NOT_CONSUMED:
          'client not consuming messages quickly enough',
  }
  for reason, count in counts_by_reason.items():
    reason_string = suppression_reason_strings.get(
        reason, 'UNKNOWN REASON: {}'.format(reason))
    handler(reason_string, count)


class _SuppressionInfoAccumulator(object):
  """Accumulates and outputs information about suppression for the tail session."""

  def __init__(self, get_now, output_warning, output_error):
    self._get_now = get_now
    self._warning = output_warning
    self._error = output_error
    self._count_by_reason_delta = collections.Counter()
    self._count_by_reason_cumulative = collections.Counter()
    self._last_flush = get_now()

  def _OutputSuppressionHelpMessage(self):
    self._warning(
        'Find guidance for suppression at {}.'.format(_HELP_PAGE_LINK))

  def _ShouldFlush(self):
    return (self._get_now() - self._last_flush
           ).total_seconds() > _SUPPRESSION_INFO_FLUSH_PERIOD_SECONDS

  def _OutputSuppressionDeltaMessage(self, reason_string, count):
    self._error('Suppressed {} entries due to {}.'.format(count, reason_string))

  def _OutputSuppressionCumulativeMessage(self, reason_string, count):
    self._warning('In total, suppressed {} messages due to {}.'.format(
        count, reason_string))

  def _Flush(self):
    self._last_flush = self._get_now()
    _HandleSuppressionCounts(self._count_by_reason_delta,
                             self._OutputSuppressionDeltaMessage)
    self._count_by_reason_cumulative += self._count_by_reason_delta
    self._count_by_reason_delta.clear()

  def Finish(self):
    self._Flush()
    _HandleSuppressionCounts(self._count_by_reason_cumulative,
                             self._OutputSuppressionCumulativeMessage)
    if self._count_by_reason_cumulative:
      self._OutputSuppressionHelpMessage()

  def Add(self, suppression_info):
    self._count_by_reason_delta += collections.Counter(
        {info.reason: info.suppressed_count for info in suppression_info})
    if self._ShouldFlush():
      self._Flush()


def _StreamEntries(get_now, output_warning, output_error, output_debug,
                   tail_stub):
  """Streams entries back from the Logging API.

  Args:
    get_now: A callable that returns the current time.
    output_warning: A callable that outputs the argument as a warning.
    output_error: A callable that outputs the argument as an error.
    output_debug: A callable that outputs the argument as debug info.
    tail_stub: The `BidiRpc` stub to use.

  Yields:
    Entries included in the tail session.
  """

  tail_stub.open()
  suppression_info_accumulator = _SuppressionInfoAccumulator(
      get_now, output_warning, output_error)
  error = None
  while tail_stub.is_active:
    try:
      response = tail_stub.recv()
    except grpc.RpcError as e:
      error = e
      break
    suppression_info_accumulator.Add(response.suppression_info)
    for entry in response.entries:
      yield entry

  if error:
    # The `grpc.RpcError` that are raised by `recv()` are actually gRPC
    # `_MultiThreadedRendezvous` objects.
    _HandleGrpcRendezvous(error, output_debug, output_warning)
  suppression_info_accumulator.Finish()
  tail_stub.close()


class LogTailer(object):
  """Streams logs using gRPC."""

  def __init__(self):
    self.client = apis.GetGapicClientInstance('logging', 'v2')
    self._exit_stack = contextlib.ExitStack()
    self._exit_stack.enter_context(self.client)
    self.tail_stub = None

  # pylint: disable=invalid-name
  def close(self):
    self.Stop()
    self._exit_stack.close()

  def TailLogs(self,
               resource_names,
               logs_filter,
               buffer_window_seconds=None,
               output_warning=log.err.Print,
               output_error=log.error,
               output_debug=log.debug,
               get_now=datetime.datetime.now):
    """Tails log entries from the Cloud Logging API.

    Args:
      resource_names: The resource names to tail.
      logs_filter: The Cloud Logging filter identifying entries to include in
        the session.
      buffer_window_seconds: The amount of time that Cloud Logging should buffer
        entries to get correct ordering, or None if the backend should use its
        default.
      output_warning: A callable that outputs the argument as a warning.
      output_error: A callable that outputs the argument as an error.
      output_debug: A callable that outputs the argument as debug.
      get_now: A callable that returns the current time.

    Yields:
      Entries for the tail session.
    """
    request = self.client.types.TailLogEntriesRequest()
    request.resource_names.extend(resource_names)
    request.filter = logs_filter

    self.tail_stub = gapic_util.MakeBidiRpc(
        self.client, self.client.logging.transport.tail_log_entries,
        initial_request=request)
    if buffer_window_seconds:
      request.buffer_window = datetime.timedelta(seconds=buffer_window_seconds)
    for entry in _StreamEntries(get_now, output_warning, output_error,
                                output_debug, self.tail_stub):
      yield entry

  def Stop(self):
    if self.tail_stub:
      self.tail_stub.close()
