/**
 * Copyright 2015 Google 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.
 */

'use strict';

var cls = require('./cls');
var hooks = require('./hooks/index.js');
var Trace = require('./trace.js');
var SpanData = require('./span-data.js');
var TraceWriter = require('./trace-writer.js');
var uuid = require('uuid');
var constants = require('./constants.js');
var tracingPolicy = require('./tracing-policy.js');
var isEqual = require('lodash.isequal');

/** @type {TraceAgent} */
var traceAgent;

/**
 * @constructor
 */
function TraceAgent(config, logger) {
  this.config_ = config;
  this.logger = logger;

  hooks.activate(this);

  this.namespace = cls.createNamespace();
  this.traceWriter = new TraceWriter(logger, config);

  this.getCls = function() {
    return cls;
  };

  this.policy = tracingPolicy.createTracePolicy(config);

  if (config.onUncaughtException !== 'ignore') {
    this.unhandledException = function() {
      traceAgent.traceWriter.flushBuffer_();
      if (config.onUncaughtException === 'flushAndExit') {
        setTimeout(function() {
          process.exit(1);
        }, 2000);
      }
    };
    process.on('uncaughtException', this.unhandledException);
  }

  logger.info('trace agent activated');
}

/**
 * Halts this agent and unpatches any patched modules.
 */
TraceAgent.prototype.stop = function() {
  hooks.deactivate();
  cls.destroyNamespace();
  this.traceWriter.stop();
  this.namespace = null;
  traceAgent = null;
  if (this.config_.onUncaughtException !== 'ignore') {
    process.removeListener('uncaughtException', this.unhandledException);
  }
  this.logger.info('trace agent deactivated');
};

/**
 * Returns the agent configuration
 * @return {object} configuration
 */
TraceAgent.prototype.config = function() {
  return this.config_;
};

/**
 * Begin a new custom span.
 * @param {string} name The name of the span.
 * @param {Object<string, string}>=} labels Labels to be attached
 *   to the newly created span.
 * @param {number=} skipFrames The number of caller frames to eliminate from
 *                            stack traces.
 * @return {SpanData} The newly created span.
 */
TraceAgent.prototype.startSpan = function(name, labels, skipFrames) {
  var rootSpan = cls.getRootContext();
  skipFrames = skipFrames || 0;
  if (rootSpan) {
    var newSpan = rootSpan.createChildSpanData(name, skipFrames + 1);
    if (labels) {
      Object.keys(labels).forEach(function(key) {
        newSpan.addLabel(key, labels[key]);
      });
    }
    return newSpan;
  } else {
    this.logger.error
      ('Spans can only be created inside a supported web framework');
    return SpanData.nullSpan;
  }
};

/**
 * Close the provided span.
 * @param {SpanData} spanData The span to be ended.
 * @param {Object<string, string}>=} labels Labels to be attached
 *   to the terminated span.
 */
TraceAgent.prototype.endSpan = function(spanData, labels) {
  if (labels) {
    Object.keys(labels).forEach(function(key) {
      spanData.addLabel(key, labels[key]);
    });
  }
  spanData.close();
};

/**
 * Run the provided function in a new span with the provided name.
 * If the provided function accepts a parameter, it is assumed to be
 * async and is given a continuation to terminate the span after its
 * work is completed.
 * @param {string} name The name of the resulting span.
 * @param {Object<string, string}>=} labels Labels to be attached
 *   to the resulting span.
 * @param {function(function()=)} fn The function to trace.
 */
TraceAgent.prototype.runInSpan = function(name, labels, fn) {
  if (typeof(labels) === 'function') {
    fn = labels;
    labels = undefined;
  }
  var span = this.startSpan(name, labels, 1);
  if (fn.length === 0) {
    fn();
    this.endSpan(span);
  } else {
    fn(this.endSpan.bind(this, span));
  }
};

/**
 * Run the provided function in a new root span with the provided
 * name. As with runInSpan, if the provided function accepts a parameter,
 * it is assumed to be asynchronous, and a callback will be passed
 * to the function to terminate the root span.
 * @param {string} name The name of the resulting root span.
 * @param {Object<string, string}>=} labels Labels to be attached
 *   to the resulting span.
 * @param {function(function()=)} fn The function to trace.
 */
TraceAgent.prototype.runInRootSpan = function(name, labels, fn) {
  if (typeof(labels) === 'function') {
    fn = labels;
    labels = undefined;
  }
  var self = this;
  this.namespace.run(function () {
    if (cls.getRootContext()) {
      self.logger.error('Can\'t nest root spans');
      return;
    }
    var span = self.createRootSpanData(name, null, null, 3, 'SPAN_KIND_UNSPECIFIED');
    if (labels) {
      Object.keys(labels).forEach(function(key) {
        span.addLabel(key, labels[key]);
      });
    }
    if (fn.length === 0) {
      fn();
      span.close();
    } else {
      fn(function () {
        span.close();
      });
    }
  });
};

/**
 * Set the name of the root transaction.
 * @param {string} name The new name for the current root transaction.
 */
TraceAgent.prototype.setTransactionName = function(name) {
  var rootSpan = cls.getRootContext();
  if (rootSpan === SpanData.nullSpan) {
    return;
  }
  if (rootSpan) {
    rootSpan.span.name = name;
  } else {
    this.logger.error('Cannot set transaction name without an active transaction');
  }
};

/**
 * Add a new key value label to the root transaction.
 * @param {string} key The key for the new label.
 * @param {string} value The value for the new label.
 */
TraceAgent.prototype.addTransactionLabel = function(key, value) {
  var rootSpan = cls.getRootContext();
  if (rootSpan) {
    rootSpan.addLabel(key, value);
  } else {
    this.logger.error('Cannot add label without an active transaction');
  }
};

/**
 * Determines whether a trace of the given name should be recorded based
 * on the current tracing policy.
 *
 * @param {object} request object
 * @param {!number} options the trace header options
 */
TraceAgent.prototype.shouldTrace = function(request, options) {
  var locallyAllowed = this.policy.shouldTrace(Date.now(), request);
  // Note: remotelyDisallowed is false if no trace options are present.
  var remotelyDisallowed = !(isNaN(options) ||
    (options & constants.TRACE_OPTIONS_TRACE_ENABLED));
  return locallyAllowed && !remotelyDisallowed;
};

/**
 * Call this from inside a namespace.run().
 * @param {string} name The name of the root span.
 * @param {string} traceId The id of the trace owning this span.
 * @param {string} parentId The id of the parent span.
 * @param {number=} skipFrames The number of caller frames to eliminate from
 *                            stack traces.
 * @param {string} spanKind The kind of root span; one of 'RPC_SERVER',
 *                          'RPC_CLIENT', or 'SPAN_KIND_UNSPECIFIED'.
 */
TraceAgent.prototype.createRootSpanData = function(name, traceId, parentId,
    skipFrames, spanKind) {
  traceId = traceId || (uuid.v4().split('-').join(''));
  parentId = parentId || '0';
  skipFrames = skipFrames || 0;
  spanKind = spanKind || 'RPC_SERVER';
  var trace = new Trace(0, traceId); // project number added later
  var spanData = new SpanData(this, trace, name, parentId, true, skipFrames + 1);
  spanData.span.kind = spanKind;
  cls.setRootContext(spanData);
  return spanData;
};

/**
 * Checks if a given request if one being made by ourselves
 */
TraceAgent.prototype.isTraceAgentRequest = function(options) {
  return options && options.headers &&
    !!options.headers[constants.TRACE_AGENT_REQUEST_HEADER];
};

/**
 * Parse a cookie-style header string to extract traceId, spandId and options
 * ex: '123456/667;o=3'
 * -> {traceId: '123456', spanId: '667', options: '3'}
 * note that we ignore trailing garbage if there is more than one '='
 * Returns null if traceId or spanId are could not be found.
 *
 * @param {string} str string representation of the trace headers
 * @return {?{traceId: string, spanId: string, options: number}}
 *         object with keys. null if there is a problem.
 */
TraceAgent.prototype.parseContextFromHeader = function(str) {
  if (this.config_.ignoreContextHeader) {
    return null;
  }
  if (!str) {
    return null;
  }
  var matches = str.match(/^([0-9a-fA-F]+)(?:\/([0-9a-fA-F]+))?(?:;o=(.*))?/);
  if (!matches || matches.length !== 4 || matches[0] !== str ||
      (matches[2] && isNaN(matches[2]))) {
    return null;
  }
  return {
    traceId: matches[1],
    spanId: matches[2],
    options: Number(matches[3])
  };
};

/**
 * Generates a trace context header value that can be used
 * to follow the associated request through other Google services.
 *
 * @param {SpanData} spanData The span to be added to headers. Must not
 *                            be the nullSpan.
 * @param {boolean} traced Whether this request was traced by the agent.
 */
TraceAgent.prototype.generateTraceContext = function(spanData, traced) {
  if (spanData === SpanData.nullSpan) {
    return '';
  }
  var header = spanData.trace.traceId + '/' + spanData.span.spanId;
  var options = traced ?
    spanData.options | constants.TRACE_OPTIONS_TRACE_ENABLED :
    spanData.options;
  header += (';o=' + options);
  return header;
};

module.exports = {
  get: function(config, logger) {
    if (traceAgent) {
      if (!isEqual(config, traceAgent.config_)) {
        traceAgent.logger.warn('New configuration does not match configuration' +
          'of existing agent. The old configuration will be used.\nNew: ' +
          JSON.stringify(config) + '\nExisting: ' +
          JSON.stringify(traceAgent.config_));
      }
      return traceAgent;
    }
    traceAgent = new TraceAgent(config, logger);
    return traceAgent;
  }
};
