var querystring = require('querystring'),
  http = require('./http'),
  fs = require('fs'),
  path = require('path'),
  url = require('url'),
  ssh = require('./ssh'),
  HttpDuplex = require('./http_duplex'),
  debug = require('debug')('modem'),
  utils = require('./utils'),
  util = require('util'),
  splitca = require('split-ca'),
  os = require('os'),
  isWin = os.type() === 'Windows_NT',
  stream = require('stream');

var defaultOpts = function () {
  var host;
  var opts = {};

  if (!process.env.DOCKER_HOST) {
    // Windows socket path: //./pipe/docker_engine ( Windows 10 )
    // Linux & Darwin socket path is /var/run/docker.sock when running system-wide,
    // or $HOME/.docker/run/docker.sock in new Docker Desktop installs.
    opts.socketPath = isWin ? '//./pipe/docker_engine' : findDefaultUnixSocket;
  } else if (process.env.DOCKER_HOST.indexOf('unix://') === 0) {
    // Strip off unix://, fall back to default if unix:// was passed without a path
    opts.socketPath = process.env.DOCKER_HOST.substring(7) || findDefaultUnixSocket;
  } else if (process.env.DOCKER_HOST.indexOf('npipe://') === 0) {
    // Strip off npipe://, fall back to default of //./pipe/docker_engine if
    // npipe:// was passed without a path
    opts.socketPath = process.env.DOCKER_HOST.substring(8) || '//./pipe/docker_engine';
  } else {
    var hostStr = process.env.DOCKER_HOST;
    if (hostStr.indexOf('\/\/') < 0) {
      hostStr = 'tcp://' + hostStr;
    }
    try {
      host = new url.URL(hostStr);
    } catch (err) {
      throw new Error('DOCKER_HOST env variable should be something like tcp://localhost:1234');
    }

    opts.port = host.port;

    if (process.env.DOCKER_TLS_VERIFY === '1' || opts.port === '2376') {
      opts.protocol = 'https';
    } else if (host.protocol === 'ssh:') {
      opts.protocol = 'ssh';
      opts.username = host.username;
      opts.sshOptions = {
        agent: process.env.SSH_AUTH_SOCK,
      }
    } else {
      opts.protocol = 'http';
    }

    if (process.env.DOCKER_PATH_PREFIX) {
      opts.pathPrefix = process.env.DOCKER_PATH_PREFIX;
    }
    else {
      opts.pathPrefix = '/';
    }

    opts.host = host.hostname;

    if (process.env.DOCKER_CERT_PATH) {
      opts.ca = splitca(path.join(process.env.DOCKER_CERT_PATH, 'ca.pem'));
      opts.cert = fs.readFileSync(path.join(process.env.DOCKER_CERT_PATH, 'cert.pem'));
      opts.key = fs.readFileSync(path.join(process.env.DOCKER_CERT_PATH, 'key.pem'));
    }

    if (process.env.DOCKER_CLIENT_TIMEOUT) {
      opts.timeout = parseInt(process.env.DOCKER_CLIENT_TIMEOUT, 10);
    }
  }

  return opts;
};

var findDefaultUnixSocket = function () {
  return new Promise(function (resolve) {
    var userDockerSocket = path.join(os.homedir(), '.docker', 'run', 'docker.sock');
    fs.access(userDockerSocket, function (err) {
      if (err) resolve('/var/run/docker.sock');
      else resolve(userDockerSocket);
    })
  });
}


var Modem = function (options) {
  var optDefaults = defaultOpts();
  var opts = Object.assign({}, optDefaults, options);

  this.host = opts.host;

  if (!this.host) {
    this.socketPath = opts.socketPath;
  }

  this.port = opts.port;
  this.pathPrefix = opts.pathPrefix;
  this.username = opts.username;
  this.password = opts.password;
  this.version = opts.version;
  this.key = opts.key;
  this.cert = opts.cert;
  this.ca = opts.ca;
  this.timeout = opts.timeout;
  this.connectionTimeout = opts.connectionTimeout;
  this.checkServerIdentity = opts.checkServerIdentity;
  this.agent = opts.agent;
  this.headers = opts.headers || {};
  this.sshOptions = Object.assign({}, options ? options.sshOptions : {}, optDefaults.sshOptions);
  //retrocompabitlity
  if (this.sshOptions.agentForward === undefined) {
    this.sshOptions.agentForward = opts.agentForward;
  }

  if (this.key && this.cert && this.ca) {
    this.protocol = 'https';
  }
  this.protocol = opts.protocol || this.protocol || 'http';
};

Modem.prototype.dial = function (options, callback) {
  var opts, address, data;

  if (options.options) {
    opts = options.options;
  }

  // Prevent credentials from showing up in URL
  if (opts && opts.authconfig) {
    delete opts.authconfig;
  }

  // Prevent abortsignal from showing up in the URL
  if (opts && opts.abortSignal) {
    delete opts.abortSignal;
  }

  if (this.version) {
    options.path = '/' + this.version + options.path;
  }

  if (this.host) {
    var parsed = url.parse(this.host);
    address = url.format({
      protocol: parsed.protocol || this.protocol,
      hostname: parsed.hostname || this.host,
      port: this.port,
      pathname: parsed.pathname || this.pathPrefix,
    });
    address = url.resolve(address, options.path);
  } else {
    address = options.path;
  }

  if (options.path.indexOf('?') !== -1) {
    if (opts && Object.keys(opts).length > 0) {
      address += this.buildQuerystring(opts._query || opts);
    } else {
      address = address.substring(0, address.length - 1);
    }
  }

  var optionsf = {
    path: address,
    method: options.method,
    headers: options.headers || Object.assign({}, this.headers),
    key: this.key,
    cert: this.cert,
    ca: this.ca
  };


  if (this.checkServerIdentity) {
    optionsf.checkServerIdentity = this.checkServerIdentity;
  }

  if (this.agent) {
    optionsf.agent = this.agent;
  }

  if (options.authconfig) {
    optionsf.headers['X-Registry-Auth'] = options.authconfig.key || options.authconfig.base64 ||
      Buffer.from(JSON.stringify(options.authconfig)).toString('base64').replace(/\+/g, "-").replace(/\//g, "_");
  }

  if (options.registryconfig) {
    optionsf.headers['X-Registry-Config'] = options.registryconfig.base64 ||
      Buffer.from(JSON.stringify(options.registryconfig)).toString('base64');
  }

  if (options.abortSignal) {
    optionsf.signal = options.abortSignal;
  }

  if (options.file) {
    if (typeof options.file === 'string') {
      data = fs.createReadStream(path.resolve(options.file));
    } else {
      data = options.file;
    }
    optionsf.headers['Content-Type'] = options?.headers?.['Content-Type'] ?? 'application/tar';
  } else if (opts && options.method === 'POST') {
    data = JSON.stringify(opts._body || opts);
    if (options.allowEmpty) {
      optionsf.headers['Content-Type'] = options?.headers?.['Content-Type'] ?? 'application/json';
    } else {
      if (data !== '{}' && data !== '""') {
        optionsf.headers['Content-Type'] = options?.headers?.['Content-Type'] ?? 'application/json';
      } else {
        data = undefined;
      }
    }
  }

  if (typeof data === 'string') {
    optionsf.headers['Content-Length'] = Buffer.byteLength(data);
  } else if (Buffer.isBuffer(data) === true) {
    optionsf.headers['Content-Length'] = data.length;
  } else if (optionsf.method === 'PUT' || options.hijack || options.openStdin) {
    optionsf.headers['Transfer-Encoding'] = 'chunked';
  }

  if (options.hijack) {
    optionsf.headers.Connection = 'Upgrade';
    optionsf.headers.Upgrade = optionsf.headers.Upgrade ?? 'tcp';
  }

  if (this.socketPath) {
    // SocketPath may be a function that can return a promise:
    this.getSocketPath().then((socketPath) => {
      optionsf.socketPath = socketPath;
      this.buildRequest(optionsf, options, data, callback);
    });
  } else {
    var urlp = url.parse(address);
    optionsf.hostname = urlp.hostname;
    optionsf.port = urlp.port;
    optionsf.path = urlp.path;

    this.buildRequest(optionsf, options, data, callback);
  }
};

Modem.prototype.getSocketPath = function () {
  if (!this.socketPath) return;
  if (this.socketPathCache) return Promise.resolve(this.socketPathCache);

  var socketPathValue = typeof this.socketPath === 'function'
    ? this.socketPath() : this.socketPath;

  this.socketPathCache = socketPathValue;

  return Promise.resolve(socketPathValue);
}

Modem.prototype.buildRequest = function (options, context, data, callback) {
  var self = this;
  var connectionTimeoutTimer;
  var finished = false;

  var opts = self.protocol === 'ssh' ? Object.assign(options, {
    agent: ssh(Object.assign({}, self.sshOptions, {
      'host': self.host,
      'port': self.port,
      'username': self.username,
      'password': self.password,
    })),
    protocol: 'http:',
  }) : options;

  var req = null;
  try {
    req = http[self.protocol === 'ssh' ? 'http' : self.protocol].request(opts, function () { });
  } catch (e) {
    callback(e);
    return;
  }

  debug('Sending: %s', util.inspect(options, {
    showHidden: true,
    depth: null
  }));

  if (self.connectionTimeout) {
    connectionTimeoutTimer = setTimeout(function () {
      debug('Connection Timeout of %s ms exceeded', self.connectionTimeout);
      req.destroy();
    }, self.connectionTimeout);
  }

  if (self.timeout) {
    req.setTimeout(self.timeout);
    req.on('timeout', function () {
      debug('Timeout of %s ms exceeded', self.timeout);
      req.destroy();
    });
  }

  if (context.hijack === true) {
    clearTimeout(connectionTimeoutTimer);
    req.on('upgrade', function (res, sock, head) {
      if (finished === false) {
        finished = true;
        if (head.length > 0) {
          sock.unshift(head);
        }
        return callback(null, sock);
      }
    });
  }

  req.on('connect', function () {
    clearTimeout(connectionTimeoutTimer);
  });

  req.on('disconnect', function () {
    clearTimeout(connectionTimeoutTimer);
  });

  req.on('response', function (res) {
    clearTimeout(connectionTimeoutTimer);
    if (context.isStream === true) {
      if (finished === false) {
        finished = true;
        self.buildPayload(null, context.isStream, context.statusCodes, context.openStdin, req, res, null, callback);
      }
    } else {
      // The native 'request' method only handles aborting during the request lifecycle not the response lifecycle.
      // We need to make the response stream abortable so that it's destroyed with an error on abort and then
      // it triggers the request 'error' event
      if (options.signal != null) {
        stream.addAbortSignal(options.signal, res)
      }
      var chunks = [];
      res.on('data', function (chunk) {
        chunks.push(chunk);
      });

      res.on('end', function () {
        var buffer = Buffer.concat(chunks);
        var result = buffer.toString();

        debug('Received: %s', result);

        var json = utils.parseJSON(result) || buffer;
        if (finished === false) {
          finished = true;
          self.buildPayload(null, context.isStream, context.statusCodes, false, req, res, json, callback);
        }
      });
    }
  });

  req.on('error', function (error) {
    clearTimeout(connectionTimeoutTimer);
    if (finished === false) {
      finished = true;
      self.buildPayload(error, context.isStream, context.statusCodes, false, {}, {}, null, callback);
    }
  });

  if (typeof data === 'string' || Buffer.isBuffer(data)) {
    req.write(data);
  } else if (data) {
    data.on('error', function (error) {
      req.destroy(error);
    });
    data.pipe(req);
  }

  if (!context.openStdin && (typeof data === 'string' || data === undefined || Buffer.isBuffer(data))) {
    req.end();
  }
};

Modem.prototype.buildPayload = function (err, isStream, statusCodes, openStdin, req, res, json, cb) {
  if (err) return cb(err, null);

  if (statusCodes[res.statusCode] !== true) {
    getCause(isStream, res, json, function (err, cause) {
      if (err) {
        return cb(err, null);
      }
      var msg = new Error(
        '(HTTP code ' + res.statusCode + ') ' +
        (statusCodes[res.statusCode] || 'unexpected') + ' - ' +
        (cause.message || cause.error || cause) + ' '
      );
      msg.reason = statusCodes[res.statusCode];
      msg.statusCode = res.statusCode;
      msg.json = json;
      cb(msg, null);
    });
  } else {
    if (openStdin) {
      cb(null, new HttpDuplex(req, res));
    } else if (isStream) {
      cb(null, res);
    } else {
      cb(null, json);
    }
  }

  function getCause(isStream, res, json, callback) {
    var chunks = '';
    var done = false;

    if (isStream) {
      res.on('data', function (chunk) {
        chunks += chunk;
      });
      res.on('error', function (err) {
        handler(err, null);
      });
      res.on('end', function () {
        handler(null, utils.parseJSON(chunks) || chunks)
      });
    } else {
      callback(null, json);
    }

    function handler(err, data) {
      if (done === false) {
        if (err) {
          callback(err);
        } else {
          callback(null, data);
        }
      }
      done = true;
    }
  }
};

Modem.prototype.demuxStream = function (streama, stdout, stderr) {
  var pendingStreamType = null;
  var pendingDataLength = null;
  var buffer = Buffer.from('');
  
  function processData(data) {
    if (data) {
      buffer = Buffer.concat([buffer, data]);
    }
    if (pendingStreamType === null) {
      if (buffer.length >= 8) {
        var header = bufferSlice(8);
        var streamType = header.readUInt8(0);
        var dataLength = header.readUInt32BE(4);

        // Validate stream type per Docker multiplex protocol:
        // 0 = stdin, 1 = stdout, 2 = stderr
        if (streamType !== 0 && streamType !== 1 && streamType !== 2) {
          // Invalid header — stream is likely not multiplexed (e.g. TTY mode)
          // or has become misaligned. Write the entire buffer as stdout
          // and stop trying to demux.
          var remaining = Buffer.concat([header, buffer]);
          stdout.write(remaining);
          buffer = Buffer.from('');
          pendingStreamType = null;
          pendingDataLength = null;
          // Switch to raw passthrough for the rest of the stream
          streama.removeListener('data', processData);
          streama.on('data', function(chunk) {
            stdout.write(chunk);
          });
          return;
        }

        pendingStreamType = streamType;
        pendingDataLength = dataLength;
        // It's possible we got a "data" that contains multiple messages
        // Process the next one
        processData();
      }
    } else {
      if (buffer.length >= pendingDataLength) {
        var content = bufferSlice(pendingDataLength);
        if (pendingStreamType === 1) {
          stdout.write(content);
        } else {
          stderr.write(content);
        }
        pendingStreamType = null;
        pendingDataLength = null;
        // It's possible we got a "data" that contains multiple messages
        // Process the next one
        processData();
      }
    }
  }

  function bufferSlice(end) {
    var out = buffer.subarray(0, end);
    buffer = Buffer.from(buffer.subarray(end, buffer.length));
    return out;
  }

  streama.on('data', processData);
};

Modem.prototype.followProgress = function (streama, onFinished, onProgress) {
  var buf = '';
  var output = [];
  var finished = false;

  streama.on('data', onStreamEvent);
  streama.on('error', onStreamError);
  streama.on('end', onStreamEnd);
  streama.on('close', onStreamEnd);

  function onStreamEvent(data) {
    buf += data.toString();
    pump();

    function pump() {
      var pos;
      while ((pos = buf.indexOf('\n')) >= 0) {
        if (pos == 0) {
          buf = buf.slice(1);
          continue;
        }
        processLine(buf.slice(0, pos));
        buf = buf.slice(pos + 1);
      }
    }

    function processLine(line) {
      if (line[line.length - 1] == '\r') line = line.substr(0, line.length - 1);
      if (line.length > 0) {
        var obj = JSON.parse(line);
        output.push(obj);
        if (onProgress) {
          onProgress(obj);
        }
      }
    }
  };

  function onStreamError(err) {
    finished = true;
    streama.removeListener('data', onStreamEvent);
    streama.removeListener('error', onStreamError);
    streama.removeListener('end', onStreamEnd);
    streama.removeListener('close', onStreamEnd);
    onFinished(err, output);
  }

  function onStreamEnd() {
    if (!finished) onFinished(null, output);
    finished = true;
  }
};

Modem.prototype.buildQuerystring = function (opts) {
  var clone = {};

  Object.keys(opts).map(function (key, i) {
    if (
      opts[key] &&
      typeof opts[key] === 'object' &&
      !Array.isArray(opts[key])
    ) {
      clone[key] = JSON.stringify(opts[key]);
    } else {
      clone[key] = opts[key];
    }
  });

  return querystring.stringify(clone);
};

module.exports = Modem;
