#!/usr/bin/env python2.7
#
# Copyright 2007 Google 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.
#




"""Web-based User Interface for appstats.

This is a simple set of webapp-based request handlers that display the
collected statistics and let you drill down on the information in
various ways.

Template files are in the templates/ subdirectory.  Static files are
in the static/ subdirectory.

The templates are written to work with either Django 0.96 or Django
1.0, and most likely they also work with Django 1.1.
"""


import cgi
import cStringIO
import email.Utils
import logging
import mimetypes
import os
import re
import sys
import time
import traceback

from google.appengine.api import users
from google.appengine.ext import admin
from google.appengine.ext import webapp
from google.appengine.ext.appstats import datamodel_pb
from google.appengine.ext.appstats import recording
from google.appengine.ext.webapp import _template
from google.appengine.ext.webapp import util

DEBUG = recording.config.DEBUG


def _add_billed_ops_to_map(billed_ops_map, billed_ops_list):
  """Adds the BilledOpProto objects to the given map.

  The map is from BilledOpProto.op to the pb.

  Args:
    billed_ops_map: The map to populate.
    billed_ops_list: List containing the BilledOpProtos to add to the map.
  """
  for billed_op in billed_ops_list:
    if billed_op.op() not in billed_ops_map:
      update_me = datamodel_pb.BilledOpProto()
      update_me.set_op(billed_op.op())
      update_me.set_num_ops(0)
      billed_ops_map[billed_op.op()] = update_me
    update_me = billed_ops_map[billed_op.op()]
    update_me.set_num_ops(update_me.num_ops() + billed_op.num_ops())


def _billed_ops_to_str(billed_ops):
  """Builds a string representation of a list of BilledOpProto."""
  ops_as_strs = []
  for op in billed_ops:
    op_name = datamodel_pb.BilledOpProto.BilledOp_Name(op.op())
    ops_as_strs.append('%s:%s' % (op_name, op.num_ops()))
  return ', '.join(ops_as_strs)


def _as_percentage_of(cost_micropennies, total_cost_micropennies):
  """The cost as a percentage of the total cost, rounded to hundredths."""
  if total_cost_micropennies == 0:
    return 0
  return round((float(cost_micropennies) / float(total_cost_micropennies))
               * 100, 1)


def render(tmplname, data):
  """Helper function to render a template."""
  here = os.path.dirname(__file__)
  tmpl = os.path.join(here, 'templates', tmplname)
  data['env'] = os.environ
  data['shell_ok'] = recording.config.SHELL_OK
  data['multithread'] = os.getenv('wsgi.multithread')
  try:
    return _template.render(tmpl, data)
  except Exception, err:
    logging.exception('Failed to render %s', tmpl)
    return 'Problematic template %s: %s' % (tmplname, err)


class AllStatsInfo(object):
  """AllStats data."""

  def __init__(self, calls, cost, billed_ops):
    self.calls = calls
    self.cost = cost
    self.billed_ops = billed_ops


class PathStatsInfo(object):
  """PathStats data."""

  def __init__(self, cost, billed_ops, num_requests, most_recent_requests):
    self.cost = cost
    self.billed_ops = billed_ops
    self.num_requests = num_requests
    self.most_recent_requests = most_recent_requests


class PivotInfo(object):
  """Pivot data. The name attribute can be an rpc or a path."""

  def __init__(self, name, calls, cost, billed_ops, cost_pct):
    self.name = name
    self.calls = calls
    self.cost = cost
    self.billed_ops = billed_ops
    self.cost_pct = cost_pct

  def to_list(self):
    """Convert to a list with values in the locations expected by the ui."""
    return [self.name, self.calls, self.cost, self.billed_ops, self.cost_pct]

  @classmethod
  def from_list(cls, values):
    return cls(values[0], values[1], values[2], values[3], values[4])


class SummaryHandler(webapp.RequestHandler):
  """Request handler for the main stats page (/stats/)."""

  def get(self):
    recording.dont_record()





    if not self.request.path.endswith('/'):
      self.redirect(self.request.path + '/')
      return


    summaries = recording.load_summary_protos()

    data = self._get_summary_data(summaries)


    self.response.out.write(render('main.html', data))

  def _get_summary_data(self, summaries):
    """Extract statistics from summaries."""
    allstats = {}
    pathstats = {}


    pivot_path_rpc = {}


    pivot_rpc_path = {}

    total_cost_micropennies = 0

    summaries = sorted(summaries,
                       key=lambda x: (-x.start_timestamp_milliseconds()))
    for index, summary in enumerate(summaries):




      path_key = recording.config.extract_key(summary)
      if path_key not in pathstats:
        pathstats[path_key] = PathStatsInfo(0, {}, 1, [index+1])
      else:
        pathstats_info = pathstats[path_key]
        pathstats_info.num_requests += 1

        if len(pathstats_info.most_recent_requests) > 10:
          if pathstats_info.most_recent_requests[-1]:

            pathstats_info.most_recent_requests.append(0)
        else:
          pathstats_info.most_recent_requests.append(index+1)
      if path_key not in pivot_path_rpc:
        pivot_path_rpc[path_key] = {}

      for x in summary.rpc_stats_list():
        rpc_key = x.service_call_name()
        total_calls = x.total_amount_of_calls()


        cost_micropennies = x.total_cost_of_calls_microdollars()
        total_cost_micropennies += cost_micropennies
        pathstats[path_key].cost += cost_micropennies
        _add_billed_ops_to_map(pathstats[path_key].billed_ops,
                               x.total_billed_ops_list())
        if rpc_key in allstats:
          allstats[rpc_key].calls += total_calls
          allstats[rpc_key].cost += cost_micropennies
        else:
          allstats[rpc_key] = AllStatsInfo(total_calls, cost_micropennies, {})
        _add_billed_ops_to_map(
            allstats[rpc_key].billed_ops, x.total_billed_ops_list())
        if rpc_key not in pivot_path_rpc[path_key]:
          pivot_path_rpc[path_key][rpc_key] = PivotInfo(rpc_key, 0, 0, {}, 0)
        pivot_path_rpc[path_key][rpc_key].calls += total_calls
        pivot_path_rpc[path_key][rpc_key].cost += cost_micropennies
        _add_billed_ops_to_map(pivot_path_rpc[path_key][rpc_key].billed_ops,
                               x.total_billed_ops_list())

        if rpc_key not in pivot_rpc_path:
          pivot_rpc_path[rpc_key] = {}
        if path_key not in pivot_rpc_path[rpc_key]:

          pivot_rpc_path[rpc_key][path_key] = PivotInfo(path_key, 0, 0, {}, 0)
        pivot_rpc_path[rpc_key][path_key].calls += total_calls
        pivot_rpc_path[rpc_key][path_key].cost += cost_micropennies
        _add_billed_ops_to_map(pivot_rpc_path[rpc_key][path_key].billed_ops,
                               x.total_billed_ops_list())





    allstats_by_count = []
    for k, v in allstats.iteritems():
      for path_vals in pivot_rpc_path[k].itervalues():
        path_vals.billed_ops = _billed_ops_to_str(
            path_vals.billed_ops.itervalues())
        path_vals.cost_pct = _as_percentage_of(
            path_vals.cost, total_cost_micropennies)




      pivot = sorted(pivot_rpc_path[k].itervalues(),
                     key=lambda x: (-x.calls, x.name))
      allstats_by_count.append((
          k, v.calls, v.cost, _billed_ops_to_str(v.billed_ops.itervalues()),
          _as_percentage_of(v.cost, total_cost_micropennies),
          [x.to_list() for x in pivot]))
    allstats_by_count.sort(key=lambda x: (-x[1], x[0]))


    pathstats_by_count = []
    for path_key, pathstats_info in pathstats.iteritems():
      rpc_count = 0
      for rpc_vals in pivot_path_rpc[path_key].itervalues():
        rpc_vals.billed_ops = _billed_ops_to_str(
            rpc_vals.billed_ops.itervalues())
        rpc_vals.cost_pct = _as_percentage_of(
            rpc_vals.cost, total_cost_micropennies)
        rpc_count += rpc_vals.calls




      pivot = sorted(pivot_path_rpc[path_key].itervalues(),
                     key=lambda x: (-x.calls, x.name))
      pathstats_by_count.append((
          path_key, rpc_count, pathstats_info.cost,
          _billed_ops_to_str(pathstats_info.billed_ops.itervalues()),
          _as_percentage_of(pathstats_info.cost, total_cost_micropennies),
          pathstats_info.num_requests,
          pathstats_info.most_recent_requests,
          [x.to_list() for x in pivot]))

    pathstats_by_count.sort(key=lambda x: (-x[1], -x[5], x[0]))


    return {'requests': summaries,
            'allstats_by_count': allstats_by_count,
            'pathstats_by_count': pathstats_by_count,
            }


class DetailsHandler(webapp.RequestHandler):
  """Request handler for the details page (/stats/details)."""

  def get(self):
    recording.dont_record()


    time_key = self.request.get('time')
    timestamp = None
    record = None
    if time_key:
      try:
        timestamp = int(time_key) * 0.001
      except Exception:
        pass
    if timestamp:
      record = recording.load_full_proto(timestamp)
    render_record(self.response, record, './file')


def render_record(response, record, file_url=None, extra_data=None):
  """Render an appstats record in detail.

  This is a minor refactoring of DetailsHandler to support an offline
  tool for analyzing Appstats data and to allow that tool to call
  the original Appstats detailed record visualization. Since the offline
  tool may read Appstats records from other sources (e.g., a downloaded file),
  we are moving the logic of DetailsHandler related to processing and
  visualizing individual Appstats records to this function. This
  function may now be called from outside this file.

  Args:
    response: An instance of the webapp response class representing
      data to be sent in response to a web request.
    record: A RequestStatProto which contains detailed Appstats recording
      for an individual request.
    file_url: Indicates the URL to be used to follow links to files in
      application source code. A default value of 'None' indicates that
      links to files in source code will not be shown.
    extra_data: Optional dict of additional parameters for template.
  """

  data = {}
  if extra_data is not None:
    data.update(extra_data)


  if record is None:

    if extra_data is None:
      response.set_status(404)

    response.out.write(render('details.html', data))
    return

  data.update(get_details_data(record, file_url))
  response.out.write(render('details.html', data))


def get_details_data(record, file_url=None):
  """ Calculate detailed appstats data for a single request.

  Args:
    record: A RequestStatProto which contains detailed Appstats recording
      for an individual request.
    file_url: Indicates the URL to be used to follow links to files in
      application source code. A default value of 'None' indicates that
      links to files in source code will not be shown.

  Returns:
    A dictionary containing detailed appstats data for a single request.
  """

  rpcstats_map = {}
  for rpc_stat in record.individual_stats_list():
    key = rpc_stat.service_call_name()
    count, real, api, rpc_cost_micropennies, billed_ops = rpcstats_map.get(
        key, (0, 0, 0, 0, {}))
    count += 1
    real += rpc_stat.duration_milliseconds()
    api += rpc_stat.api_mcycles()


    rpc_cost_micropennies += rpc_stat.call_cost_microdollars()
    _add_billed_ops_to_map(billed_ops, rpc_stat.billed_ops_list())
    rpcstats_map[key] = (count, real, api, rpc_cost_micropennies, billed_ops)
  rpcstats_by_count = [
      (name, count, real, recording.mcycles_to_msecs(api),
       rpc_cost_micropennies, _billed_ops_to_str(billed_ops.itervalues()))
      for name, (count, real, api, rpc_cost_micropennies, billed_ops)
      in rpcstats_map.iteritems()]
  rpcstats_by_count.sort(key=lambda x: -x[1])


  real_total = 0
  api_total_mcycles = 0
  for i, rpc_stat in enumerate(record.individual_stats_list()):
    real_total += rpc_stat.duration_milliseconds()
    api_total_mcycles += rpc_stat.api_mcycles()

  api_total = recording.mcycles_to_msecs(api_total_mcycles)

  return {'sys': sys,
          'record': record,
          'rpcstats_by_count': rpcstats_by_count,
          'real_total': real_total,
          'api_total': api_total,
          'file_url': file_url,
          }


class ShellHandler(webapp.RequestHandler):
  """Request handler for interactive shell.

  This is like /_ah/admin/interactive, but with Appstats output integrated.

  GET displays a form; POST runs some code and displays its output + stats.
  """

  def _check_access(self):
    if recording.config.SHELL_OK:
      return True
    self.response.set_status(403)
    self.response.out.write('You must enable this feature by setting '
                            'appstats_SHELL_OK = True in appengine_config.py')
    return False

  def get(self):
    recording.dont_record()
    if not self._check_access():
      return
    script = self.request.get('script', recording.config.DEFAULT_SCRIPT)
    extra_data = {'is_shell': True,
                  'script': script,
                  'xsrf_token': admin.get_xsrf_token(),
                  }
    render_record(self.response, None, './file', extra_data)

  @admin.xsrf_required
  def post(self):
    recording.dont_record()
    if not self._check_access():
      return

    recorder = recording.Recorder(os.environ)
    recording.recorder_proxy.set_for_current_request(recorder)

    script = self.request.get('script', '').replace('\r\n', '\n')
    output, errors = self.execute_script(script)

    recording.recorder_proxy.clear_for_current_request()
    recorder.record_http_status(0)
    recorder.save()
    record = recorder.get_full_proto()

    extra_data = {'is_shell': True,
                  'script': script,
                  'output': output,
                  'errors': errors,
                  'time_key': int(recorder.start_timestamp * 1000),
                  'xsrf_token': admin.get_xsrf_token(),
                  }
    render_record(self.response, record, './file', extra_data)

  def execute_script(self, script):
    save_stdout = sys.stdout
    save_stderr = sys.stderr
    new_stdout = cStringIO.StringIO()
    new_stderr = cStringIO.StringIO()
    try:
      sys.stdout = new_stdout
      sys.stderr = new_stderr
      exec(script, {})
    except BaseException:
      traceback.print_exc()
    finally:
      sys.stdout = save_stdout
      sys.stderr = save_stderr
      return new_stdout.getvalue(), new_stderr.getvalue()


class FileHandler(webapp.RequestHandler):
  """Request handler for displaying any text file in the system.

  NOTE: This gives any admin of your app full access to your source code.
  """








  def get(self):
    recording.dont_record()

    lineno = self.request.get('n')
    try:
      lineno = int(lineno)
    except:
      lineno = 0

    filename = self.request.get('f') or ''
    orig_filename = filename

    match = re.match('<path\[(\d+)\]>(.*)', filename)
    if match:
      index, tail = match.groups()
      index = int(index)
      if index < len(sys.path):
        filename = sys.path[index] + tail

    try:
      fp = open(filename)
    except IOError, err:
      self.response.out.write('<h1>IOError</h1><pre>%s</pre>' %
                              cgi.escape(str(err)))
      self.response.set_status(404)
    else:

      try:
        data = {'fp': fp,
                'filename': filename,
                'orig_filename': orig_filename,
                'lineno': lineno,
                }
        self.response.out.write(render('file.html', data))
      finally:
        fp.close()


class StaticHandler(webapp.RequestHandler):
  """Request handler to serve static files.

  Only files directory in the static subdirectory are rendered this
  way (no subdirectories).
  """

  def get(self):
    recording.dont_record()
    here = os.path.dirname(__file__)
    fn = self.request.path
    i = fn.rfind('/')
    fn = fn[i+1:]
    fn = os.path.join(here, 'static', fn)
    ctype, encoding = mimetypes.guess_type(fn)
    assert ctype and '/' in ctype, repr(ctype)
    expiry = 3600
    expiration = email.Utils.formatdate(time.time() + expiry, usegmt=True)
    fp = open(fn, 'rb')
    try:
      self.response.out.write(fp.read())
    finally:
      fp.close()
    self.response.headers['Content-type'] = ctype
    self.response.headers['Cache-Control'] = 'public, max-age=expiry'
    self.response.headers['Expires'] = expiration




URLMAP = [
  ('.*/details', DetailsHandler),
  ('.*/shell', ShellHandler),
  ('.*/file', FileHandler),
  ('.*/static/.*', StaticHandler),
  ('.*', SummaryHandler),
  ]


class AuthCheckMiddleware(object):
  """Middleware which conducts an auth check."""

  def __init__(self, application):
    self._application = application

  def __call__(self, environ, start_response):
    if not environ.get('SERVER_SOFTWARE', '').startswith('Dev'):
      if not users.is_current_user_admin():
        if users.get_current_user() is None:
          start_response('302 Found',
                         [('Location',
                           users.create_login_url(os.getenv('PATH_INFO', '')))])
          return []
        else:
          start_response('403 Forbidden', [])
          return ['Forbidden\n']
    return self._application(environ, start_response)

app = AuthCheckMiddleware(webapp.WSGIApplication(URLMAP, debug=DEBUG))


def main():
  """Main program. Run the auth checking middleware wrapped WSGIApplication."""
  util.run_bare_wsgi_app(app)


if __name__ == '__main__':
  main()
