#!/usr/bin/env python
# Copyright (c) 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from __future__ import print_function

import contextlib
import functools
import json
import os
import sys
import tempfile
import threading
import time
import traceback

try:
  import urllib2 as urllib
except ImportError:  # For Py3 compatibility
  import urllib.request as urllib

import detect_host_arch
import gclient_utils
import metrics_utils
import subprocess2


DEPOT_TOOLS = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(DEPOT_TOOLS, 'metrics.cfg')
UPLOAD_SCRIPT = os.path.join(DEPOT_TOOLS, 'upload_metrics.py')

DISABLE_METRICS_COLLECTION = os.environ.get('DEPOT_TOOLS_METRICS') == '0'
DEFAULT_COUNTDOWN = 10

INVALID_CONFIG_WARNING = (
    'WARNING: Your metrics.cfg file was invalid or nonexistent. A new one will '
    'be created.'
)
PERMISSION_DENIED_WARNING = (
    'Could not write the metrics collection config:\n\t%s\n'
    'Metrics collection will be disabled.'
)


class _Config(object):
  def __init__(self):
    self._initialized = False
    self._config = {}

  def _ensure_initialized(self):
    if self._initialized:
      return

    try:
      config = json.loads(gclient_utils.FileRead(CONFIG_FILE))
    except (IOError, ValueError):
      config = {}

    self._config = config.copy()

    if 'is-googler' not in self._config:
      # /should-upload is only accessible from Google IPs, so we only need to
      # check if we can reach the page. An external developer would get access
      # denied.
      try:
        req = urllib.urlopen(metrics_utils.APP_URL + '/should-upload')
        self._config['is-googler'] = req.getcode() == 200
      except (urllib.URLError, urllib.HTTPError):
        self._config['is-googler'] = False

    # Make sure the config variables we need are present, and initialize them to
    # safe values otherwise.
    self._config.setdefault('countdown', DEFAULT_COUNTDOWN)
    self._config.setdefault('opt-in', None)
    self._config.setdefault('version', metrics_utils.CURRENT_VERSION)

    if config != self._config:
      print(INVALID_CONFIG_WARNING, file=sys.stderr)
      self._write_config()

    self._initialized = True

  def _write_config(self):
    try:
      gclient_utils.FileWrite(CONFIG_FILE, json.dumps(self._config))
    except IOError as e:
      print(PERMISSION_DENIED_WARNING % e, file=sys.stderr)
      self._config['opt-in'] = False

  @property
  def version(self):
    self._ensure_initialized()
    return self._config['version']

  @property
  def is_googler(self):
    self._ensure_initialized()
    return self._config['is-googler']

  @property
  def opted_in(self):
    self._ensure_initialized()
    return self._config['opt-in']

  @opted_in.setter
  def opted_in(self, value):
    self._ensure_initialized()
    self._config['opt-in'] = value
    self._config['version'] = metrics_utils.CURRENT_VERSION
    self._write_config()

  @property
  def countdown(self):
    self._ensure_initialized()
    return self._config['countdown']

  @property
  def should_collect_metrics(self):
    # Don't collect the metrics unless the user is a googler, the user has opted
    # in, or the countdown has expired.
    if not self.is_googler:
      return False
    if self.opted_in is False:
      return False
    if self.opted_in is None and self.countdown > 0:
      return False
    return True

  def decrease_countdown(self):
    self._ensure_initialized()
    if self.countdown == 0:
      return
    self._config['countdown'] -= 1
    if self.countdown == 0:
      self._config['version'] = metrics_utils.CURRENT_VERSION
    self._write_config()

  def reset_config(self):
    # Only reset countdown if we're already collecting metrics.
    if self.should_collect_metrics:
      self._ensure_initialized()
      self._config['countdown'] = DEFAULT_COUNTDOWN
      self._config['opt-in'] = None


class MetricsCollector(object):
  def __init__(self):
    self._metrics_lock = threading.Lock()
    self._reported_metrics = {}
    self._config = _Config()
    self._collecting_metrics = False
    self._collect_custom_metrics = True

  @property
  def config(self):
    return self._config

  @property
  def collecting_metrics(self):
    return self._collecting_metrics

  def add(self, name, value):
    if self._collect_custom_metrics:
      with self._metrics_lock:
        self._reported_metrics[name] = value

  def add_repeated(self, name, value):
    if self._collect_custom_metrics:
      with self._metrics_lock:
        self._reported_metrics.setdefault(name, []).append(value)

  @contextlib.contextmanager
  def pause_metrics_collection(self):
    collect_custom_metrics = self._collect_custom_metrics
    self._collect_custom_metrics = False
    try:
      yield
    finally:
      self._collect_custom_metrics = collect_custom_metrics

  def _upload_metrics_data(self):
    """Upload the metrics data to the AppEngine app."""
    # We invoke a subprocess, and use stdin.write instead of communicate(),
    # so that we are able to return immediately, leaving the upload running in
    # the background.
    p = subprocess2.Popen(['vpython3', UPLOAD_SCRIPT], stdin=subprocess2.PIPE)
    p.stdin.write(json.dumps(self._reported_metrics).encode('utf-8'))

  def _collect_metrics(self, func, command_name, *args, **kwargs):
    # If we're already collecting metrics, just execute the function.
    # e.g. git-cl split invokes git-cl upload several times to upload each
    # split CL.
    if self.collecting_metrics:
      # Don't collect metrics for this function.
      # e.g. Don't record the arguments git-cl split passes to git-cl upload.
      with self.pause_metrics_collection():
        return func(*args, **kwargs)

    self._collecting_metrics = True
    self.add('metrics_version', metrics_utils.CURRENT_VERSION)
    self.add('command', command_name)
    try:
      start = time.time()
      result = func(*args, **kwargs)
      exception = None
    # pylint: disable=bare-except
    except:
      exception = sys.exc_info()
    finally:
      self.add('execution_time', time.time() - start)

    exit_code = metrics_utils.return_code_from_exception(exception)
    self.add('exit_code', exit_code)

    # Add metrics regarding environment information.
    self.add('timestamp', int(time.time()))
    self.add('python_version', metrics_utils.get_python_version())
    self.add('host_os', gclient_utils.GetMacWinAixOrLinux())
    self.add('host_arch', detect_host_arch.HostArch())

    depot_tools_age = metrics_utils.get_repo_timestamp(DEPOT_TOOLS)
    if depot_tools_age is not None:
      self.add('depot_tools_age', int(depot_tools_age))

    git_version = metrics_utils.get_git_version()
    if git_version:
      self.add('git_version', git_version)

    self._upload_metrics_data()
    if exception:
      gclient_utils.reraise(exception[0], exception[1], exception[2])
    return result

  def collect_metrics(self, command_name):
    """A decorator used to collect metrics over the life of a function.

    This decorator executes the function and collects metrics about the system
    environment and the function performance.
    """
    def _decorator(func):
      # Do this first so we don't have to read, and possibly create a config
      # file.
      if DISABLE_METRICS_COLLECTION:
        return func
      if not self.config.should_collect_metrics:
        return func
      # Otherwise, collect the metrics.
      # Needed to preserve the __name__ and __doc__ attributes of func.
      @functools.wraps(func)
      def _inner(*args, **kwargs):
        return self._collect_metrics(func, command_name, *args, **kwargs)
      return _inner
    return _decorator

  @contextlib.contextmanager
  def print_notice_and_exit(self):
    """A context manager used to print the notice and terminate execution.

    This decorator executes the function and prints the monitoring notice if
    necessary. If an exception is raised, we will catch it, and print it before
    printing the metrics collection notice.
    This will call sys.exit() with an appropriate exit code to ensure the notice
    is the last thing printed."""
    # Needed to preserve the __name__ and __doc__ attributes of func.
    try:
      yield
      exception = None
    # pylint: disable=bare-except
    except:
      exception = sys.exc_info()

    # Print the exception before the metrics notice, so that the notice is
    # clearly visible even if gclient fails.
    if exception:
      if isinstance(exception[1], KeyboardInterrupt):
        sys.stderr.write('Interrupted\n')
      elif not isinstance(exception[1], SystemExit):
        traceback.print_exception(*exception)

    # Check if the version has changed
    if (not DISABLE_METRICS_COLLECTION and self.config.is_googler
        and self.config.opted_in is not False
        and self.config.version != metrics_utils.CURRENT_VERSION):
      metrics_utils.print_version_change(self.config.version)
      self.config.reset_config()

    # Print the notice
    if (not DISABLE_METRICS_COLLECTION and self.config.is_googler
        and self.config.opted_in is None):
      metrics_utils.print_notice(self.config.countdown)
      self.config.decrease_countdown()

    sys.exit(metrics_utils.return_code_from_exception(exception))


collector = MetricsCollector()
