/*!
 *
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 SwPlugin, { wrapEmit } from '../core/SwPlugin';
import { URL } from 'url';
import { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'http';
import ContextManager from '../trace/context/ContextManager';
import { Component } from '../trace/Component';
import Tag from '../Tag';
import Span from '../trace/span/Span';
import { SpanLayer } from '../proto/language-agent/Tracing_pb';
import { ContextCarrier } from '../trace/context/ContextCarrier';
import DummySpan from '../trace/span/DummySpan';
import { ignoreHttpMethodCheck } from '../config/AgentConfig';

class HttpPlugin implements SwPlugin {
  readonly module = 'http';
  readonly versions = '*';
  readonly isBuiltIn = true;

  install(): void {
    const http = require('http');
    const https = require('https');

    this.interceptClientRequest(http, 'http');
    this.interceptServerRequest(http, 'http');
    this.interceptClientRequest(https, 'https');
    this.interceptServerRequest(https, 'https');
  }

  private interceptClientRequest(module: any, protocol: string) {
    const _request = module.request; // BUG! this doesn't work with "import {request} from http", but haven't found an alternative yet

    module.request = function () {
      const url: URL | string | RequestOptions = arguments[0];

      const { host, pathname } =
        url instanceof URL
          ? url
          : typeof url === 'string'
          ? new URL(url) // TODO: this may throw invalid URL
          : {
              host: (url.host || url.hostname || 'unknown') + ':' + (url.port || 80),
              pathname: url.path || '/',
            };

      const operation = pathname.replace(/\?.*$/g, '');
      const method = arguments[url instanceof URL || typeof url === 'string' ? 1 : 0]?.method || 'GET';
      const span = ignoreHttpMethodCheck(method)
        ? DummySpan.create()
        : ContextManager.current.newExitSpan(operation, Component.HTTP);

      if (span.depth)
        // if we inherited from a higher level plugin then do nothing, higher level should do all the work and we don't duplicate here
        return _request.apply(this, arguments);

      span.start();

      try {
        span.component = Component.HTTP;
        span.layer = SpanLayer.HTTP;
        span.peer = host;

        span.tag(Tag.httpURL(protocol + '://' + host + pathname));
        span.tag(Tag.httpMethod(method));

        const copyStatusAndWrapEmit = (res: any) => {
          span.tag(Tag.httpStatusCode(res.statusCode));

          if (res.statusCode && res.statusCode >= 400) span.errored = true;

          if (res.statusMessage) span.tag(Tag.httpStatusMsg(res.statusMessage));

          wrapEmit(span, res, false, 'end');
        };

        const responseCB = function (this: any, res: any) {
          // may wrap callback instead of event because it procs first
          span.resync();

          copyStatusAndWrapEmit(res);

          try {
            if (callback) return callback.apply(this, arguments);
          } catch (err) {
            span.error(err);

            throw err;
          } finally {
            span.async();
          }
        };

        const idxCallback = typeof arguments[2] === 'function' ? 2 : typeof arguments[1] === 'function' ? 1 : 0;
        const callback = arguments[idxCallback];

        if (idxCallback) arguments[idxCallback] = responseCB;

        let arg0 = arguments[0];
        const expect = arg0.headers && (arg0.headers.Expect || arg0.headers.expect);

        if (expect === '100-continue') {
          span.inject().items.forEach((item) => {
            arg0.headers[item.key] = item.value;
          });
        }

        const req: ClientRequest = _request.apply(this, arguments);

        span
          .inject()
          .items.filter((item) => expect != '100-continue')
          .forEach((item) => req.setHeader(item.key, item.value));

        wrapEmit(span, req, true, 'close');

        req.on('timeout', () => span.log('Timeout', true));
        req.on('abort', () => span.log('Abort', (span.errored = true)));

        if (!idxCallback) req.on('response', copyStatusAndWrapEmit);

        span.async();

        return req;
      } catch (err) {
        span.error(err);
        span.stop();

        throw err;
      }
    };
  }

  private interceptServerRequest(module: any, protocol: string) {
    const plugin = this;
    const _addListener = module.Server.prototype.addListener; // TODO? full event protocol support not currently implemented (prependListener(), removeListener(), etc...)

    module.Server.prototype.addListener = module.Server.prototype.on = function (
      event: any,
      handler: any,
      ...addArgs: any[]
    ) {
      return _addListener.call(this, event, event === 'request' ? _sw_request : handler, ...addArgs);

      function _sw_request(this: any, req: IncomingMessage, res: ServerResponse, ...reqArgs: any[]) {
        const carrier = ContextCarrier.from((req as any).headers || {});
        const reqMethod = req.method ?? 'GET';
        const operation = reqMethod + ':' + (req.url || '/').replace(/\?.*/g, '');
        const span = ignoreHttpMethodCheck(reqMethod)
          ? DummySpan.create()
          : ContextManager.current.newEntrySpan(operation, carrier);

        span.component = Component.HTTP_SERVER;

        span.tag(Tag.httpURL(protocol + '://' + (req.headers.host || '') + req.url));

        return plugin.wrapHttpResponse(span, req, res, () => handler.call(this, req, res, ...reqArgs));
      }
    };
  }

  wrapHttpResponse(span: Span, req: IncomingMessage, res: ServerResponse, handler: any): any {
    span.start();

    try {
      span.layer = SpanLayer.HTTP;
      span.peer =
        (typeof req.headers['x-forwarded-for'] === 'string' && req.headers['x-forwarded-for'].split(',').shift()) ||
        (req.connection.remoteFamily === 'IPv6'
          ? `[${req.connection.remoteAddress}]:${req.connection.remotePort}`
          : `${req.connection.remoteAddress}:${req.connection.remotePort}`);

      span.tag(Tag.httpMethod(req.method ?? 'GET'));

      const ret = handler();

      const stopper = (stopEvent: any) => {
        const stop = (emittedEvent: any) => {
          if (emittedEvent === stopEvent) {
            span.tag(Tag.httpStatusCode(res.statusCode));

            if (res.statusCode && res.statusCode >= 400) span.errored = true;

            if (res.statusMessage) span.tag(Tag.httpStatusMsg(res.statusMessage));

            return true;
          }
        };

        return stop;
      };

      const isSub12 = process.version < 'v12';

      wrapEmit(span, req, true, isSub12 ? stopper('end') : NaN);
      wrapEmit(span, res, true, isSub12 ? NaN : stopper('close'));

      span.async();

      return ret;
    } catch (err) {
      span.error(err);
      span.stop();

      throw err;
    }
  }
}

// noinspection JSUnusedGlobalSymbols
export default new HttpPlugin();
