// Copyright 2019 (c) Karl Cauchy
// Rearranged from https://github.com/oyyd/http-proxy-to-socks
// LICENSE: See at LICENSE.md

// inspired by https://github.com/asluchevskiy/http-to-socks-proxy

import * as fs from 'fs';
import * as http from 'http';
import * as matcher from 'matcher';
import { Socket } from 'net';
import * as url from 'url';

import { SocksClient } from 'socks';
import { SocksProxyType } from 'socks/typings/common/constants';

import { IAuthentication, IProxyInfo, SocksProxyAgent } from './Agent';

import { getLogger } from './Logger';

type Callback = () => IProxyInfo;

interface ISocketInfo {
  socket: Socket;
}

export interface IProxy extends url.Url {
  socks?: string;

  nport?: number;

  /**
   * Whether use remote DNS instead of local solution.
   */
  lookup?: boolean;

  version?: number;

  authentication?: IAuthentication;
}

export interface IConfig {
  proxies: IProxy[];
  excludes: string[];
  includes: string[];

  forceTunneling: boolean;
}

export class ProxyServer extends http.Server {

  private proxyList: IProxyInfo[] = [];
  private includes: string[] = [];
  private excludes: string[] = [];
  private forceTunneling: boolean;

  constructor(options: IConfig) {
    super();

    // if (options.socks) {
    //   // stand alone proxy loging
    //   this.loadProxy(options.socks);
    // } else if (options.socksListFileName) {
    //   // proxy list loading
    //   this.loadProxyFile(options.socksListFileName);
    //   if (options.proxyListReloadTimeout) {
    //     setInterval(
    //       () => {
    //         this.loadProxyFile(options.socksListFileName);
    //       },
    //       options.proxyListReloadTimeout * 1000,
    //     );
    //   }
    // }

    this.loadProxies(options.proxies);

    this.addListener(
      'request',
      this.requestListener.bind(this, this.onReadElement),
    );
    this.addListener(
      'connect',
      this.connectListener.bind(this, this.onReadElement),
    );

    this.includes = options.includes;
    this.excludes = options.excludes;
    this.forceTunneling = options.forceTunneling;
  }

  public loadProxy(proxyLine: string | IProxy) {
    try {
      this.proxyList.push(this.parseProxyLine(proxyLine));
    } catch (ex) {
      getLogger().error(ex.message);
    }
  }

  public loadProxies(proxies: IProxy[]) {
    for (const proxy of proxies) {
      this.loadProxy(proxy);
    }
  }

  public getProxyObject(host: string,
                        port: string,
                        login: string,
                        password: string): IProxyInfo {
    return {
      authentication: { username: login || '', password: password || '' },
      command: 'connect',
      host,
      lookup: true,
      port: parseInt(port, 10),
      version: 5,
    };
  }

  public parseProxyLine(line: string | IProxy): IProxyInfo {
    if (typeof line === 'string') {
      const proxyInfo = line.split(':');

      if (proxyInfo.length !== 4 && proxyInfo.length !== 2) {
        throw new Error(`Incorrect proxy line: ${line}`);
      }

      return this.getProxyObject.apply(this, proxyInfo);
    }

    // Directly use IProxy.
    return {
      authentication: line.authentication,
      command: 'connect',
      host: line.host,
      lookup: line.lookup,
      port: line.nport,
      version: line.version,
    };
  }

  public loadProxyFile(fileName: string) {
    getLogger().info(`Loading proxy list from file: ${fileName}`);

    fs.readFile(fileName, (err, data) => {
      if (err) {
        getLogger().error(`Impossible to read the proxy file : ${fileName} error : ${err.message}`);
        return;
      }

      const lines = data.toString().split('\n');
      const proxyList = [];
      for (let i = 0; i < lines.length; i += 1) {
        if (!(lines[i] !== '' && lines[i].charAt(0) !== '#')) {
          try {
            proxyList.push(this.parseProxyLine(lines[i]));
          } catch (ex) {
            getLogger().error(ex.message);
          }
        }
      }
      this.proxyList = proxyList;
    });
  }

  // ----------- Private functions -----------

  private randomElement(array: IProxyInfo[]): IProxyInfo {
    return array[Math.floor(Math.random() * array.length)];
  }

  private onReadElement = (): IProxyInfo => {
    return this.randomElement(this.proxyList);
  }

  private doUseAgent(host: string) {
    if (this.forceTunneling) {
      return false;
    }

    // Check if we should use the socksAgent.
    const includes = matcher([host], this.includes);
    const excludes = matcher([host], this.excludes);

    let useAgent = false;
    if (includes.length > 0) {
      // Then this is considered to be yes.
      useAgent = true;
    }

    if (excludes.length > 0) {
      useAgent = false;
    }

    return useAgent;
  }

  private requestListener(getProxyInfo: Callback,
                          request: http.IncomingMessage,
                          response: http.ServerResponse) {
    getLogger().info(`request: ${request.url}`);

    const proxy = getProxyInfo();
    const ph = url.parse(request.url);

    // const port = parseInt(ph.port, 10) || 80;
    const socksAgent = new SocksProxyAgent(proxy);

    // Check if we should use the socksAgent.
    const useAgent = this.doUseAgent(ph.hostname);

    const options = {
      agent: useAgent ? socksAgent : http.globalAgent,
      headers: request.headers,
      hostname: ph.hostname,
      method: request.method,
      path: ph.path,
      port: ph.port,
    };

    const proxyRequest = http.request(options);

    request.on('error', (err) => {
      getLogger().error(`${err.message}`);
      proxyRequest.destroy(err);
    });

    proxyRequest.on('error', (error) => {
      getLogger().error(`${error.message} on proxy ${proxy.host}:${proxy.port}`);
      response.writeHead(500);
      response.end('Connection error\n');
    });

    proxyRequest.on('response', (proxyResponse) => {
      proxyResponse.pipe(response);
      response.writeHead(proxyResponse.statusCode, proxyResponse.headers);
    });

    request.pipe(proxyRequest);
  }

  private connectListener(getProxyInfo: Callback,
                          request: http.IncomingMessage,
                          socketRequest: Socket,
                          head) {
    getLogger().debug(`connect: ${request.url}`);

    const proxy = getProxyInfo();

    const ph = url.parse(`http://${request.url}`);
    const { hostname: host, port } = ph;

    const options = {
      command: proxy.command,
      destination: { host, port: parseInt(port, 10) },
      proxy: {
        host: proxy.host,
        password: '',
        port: proxy.port,
        type: proxy.version as SocksProxyType,
        userId: '',
      },
      timeout: proxy.timeout,
      type: proxy.version,
    };

    if (proxy.authentication) {
      options.proxy.userId = proxy.authentication.username;
      options.proxy.password = proxy.authentication.password;
    }

    let socket: Socket;

    socketRequest.on('error', (err: Error) => {
      getLogger().error(`${err.message}`);
      if (socket) {
        socket.destroy(err);
      }
    });

    // Check if we should use the socksAgent.
    const useAgent = this.doUseAgent(host);

    function tunneling(error: Error, tSocket: Socket) {
      socket = tSocket;

      if (error) {
        // error in SocksSocket creation
        getLogger()
          .error(`${error.message} connection creating on ${proxy.host}:${proxy.port}`);
        socketRequest.write(`HTTP/${request.httpVersion} 500 Connection error\r\n\r\n`);
        return;
      }

      socket.on('error', (err: Error) => {
        getLogger().error(`${err.message}`);
        socketRequest.destroy(err);
      });

      // tunneling to the host
      socket.pipe(socketRequest);
      socketRequest.pipe(socket);

      socket.write(head);
      socketRequest.write(`HTTP/${request.httpVersion} 200 Connection established\r\n\r\n`);
      socket.resume();
    }

    if (!useAgent) {
      // Direct connection
      socket = new Socket();
      socket.connect({ host, port: parseInt(port, 10) || 443 }, (error: Error) => {
        getLogger().info(`Direct connect: ${request.url}`);
        tunneling(error, socket);
      });

      return;
    }

    SocksClient.createConnection(options, (error: Error,
                                           // req: http.IncomingMessage,
                                           originalSocket: ISocketInfo) => {
      getLogger().info(`Tunneling connect: ${request.url}`);
      tunneling(error, originalSocket.socket);
    });
  }
}
