import bytes from 'bytes';
import chalk from 'chalk';
import {
  npm as getNpmFiles,
  docker as getDockerFiles
} from './get-files';
import ua from './ua';
import hash from './hash';
import retry from 'async-retry';
import Agent from './agent';
import EventEmitter from 'events';
import { basename, resolve as resolvePath } from 'path';
import { homedir } from 'os';
import { parse as parseIni } from 'ini';
import { readFile } from 'fs-promise';
import resumer from 'resumer';
import splitArray from 'split-array';
import { parse as parseDockerfile } from 'docker-file-parser';

// how many concurrent HTTP/2 stream uploads
const MAX_CONCURRENT = 10;

// check if running windows
const IS_WIN = /^win/.test(process.platform);
const SEP = IS_WIN ? '\\' : '/';

export default class Now extends EventEmitter {
  constructor (url, token, { forceNew = false, debug = false }) {
    super();
    this._token = token;
    this._debug = debug;
    this._forceNew = forceNew;
    this._agent = new Agent(url, { debug });
    this._onRetry = this._onRetry.bind(this);
  }

  async create (path, {
    wantsPublic,
    quiet = false,
    env = {},
    forceNew = false,
    forceSync = false,
    forwardNpm = false,
    deploymentType = 'npm'
  }) {
    this._path = path;

    let pkg = {};
    let name, description, files;

    if ('npm' === deploymentType) {
      try {
        pkg = await readFile(resolvePath(path, 'package.json'));
        pkg = JSON.parse(pkg);
      } catch (err) {
        const e = Error(`Failed to read JSON in "${path}/package.json"`);
        e.userError = true;
        throw e;
      }

      if (!pkg.scripts || (!pkg.scripts.start && !pkg.scripts['now-start'])) {
        const e = Error('Missing `start` (or `now-start`) script in `package.json`. ' +
          'See: https://docs.npmjs.com/cli/start.');
        e.userError = true;
        throw e;
      }

      if (null == pkg.name || 'string' !== typeof pkg.name) {
        name = basename(path);
        if (!quiet) console.log(`> No \`name\` in \`package.json\`, using ${chalk.bold(name)}`);
      } else {
        name = pkg.name;
      }

      description = pkg.description;

      if (this._debug) console.time('> [debug] Getting files');
      files = await getNpmFiles(path, pkg, { debug: this._debug });
      if (this._debug) console.timeEnd('> [debug] Getting files');
    } else if ('docker' === deploymentType) {
      let docker;
      try {
        const dockerfile = await readFile(resolvePath(path, 'Dockerfile'), 'utf8');
        docker = parseDockerfile(dockerfile, { includeComments: true });
      } catch (err) {
        const e = Error(`Failed to parse "${path}/Dockerfile"`);
        e.userError = true;
        throw e;
      }

      if (!docker.length) {
        const e = Error('No commands found in `Dockerfile`');
        e.userError = true;
        throw e;
      }

      if (!docker.some((cmd) => 'CMD' === cmd.name)) {
        const e = Error('No `CMD` found in `Dockerfile`. ' +
          'See: https://docs.docker.com/engine/reference/builder/#/cmd');
        e.userError = true;
        throw e;
      }

      if (!docker.some((cmd) => 'EXPOSE' === cmd.name)) {
        const e = Error('No `EXPOSE` found in `Dockerfile`. A port must be supplied. ' +
          'See: https://docs.docker.com/engine/reference/builder/#/expose');
        e.userError = true;
        throw e;
      }

      const labels = {};
      docker
      .filter(cmd => 'LABEL' === cmd.name)
      .forEach(({ args }) => {
        for (let key in args) {
          // unescape and convert into string
          try {
            labels[key] = JSON.parse(args[key]);
          } catch (err) {
            const e = Error(`Error parsing value for LABEL ${key} in \`Dockerfile\``);
            e.userError = true;
            throw e;
          }
        }
      });

      if (null == labels.name) {
        name = basename(path);
        if (!quiet) console.log(`> No \`name\` LABEL in \`Dockerfile\`, using ${chalk.bold(name)}`);
      } else {
        name = labels.name;
      }

      description = labels.description;

      if (this._debug) console.time('> [debug] Getting files');
      files = await getDockerFiles(path, { debug: this._debug });
      if (this._debug) console.timeEnd('> [debug] Getting files');
    }

    const nowProperties = pkg ? pkg.now || {} : {};

    forwardNpm = forwardNpm || nowProperties['forwardNpm'];

    // Read .npmrc
    let npmrc = {};
    let authToken;
    if ('npm' === deploymentType && forwardNpm) {
      try {
        npmrc = await readFile(resolvePath(path, '.npmrc'), 'utf8');
        npmrc = parseIni(npmrc);
        authToken = npmrc['//registry.npmjs.org/:_authToken'];
      } catch (err) {
        // Do nothing
      }

      if (!authToken) {
        try {
          npmrc = await readFile(resolvePath(homedir(), '.npmrc'), 'utf8');
          npmrc = parseIni(npmrc);
          authToken = npmrc['//registry.npmjs.org/:_authToken'];
        } catch (err) {
          // Do nothing
        }
      }
    }

    if (this._debug) console.time('> [debug] Computing hashes');
    const hashes = await hash(files);
    if (this._debug) console.timeEnd('> [debug] Computing hashes');

    this._files = hashes;

    const engines = nowProperties.engines || pkg.engines;

    const deployment = await this.retry(async (bail) => {
      if (this._debug) console.time('> [debug] /now/create');
      const res = await this._fetch('/now/create', {
        method: 'POST',
        body: {
          env,
          public: wantsPublic,
          forceNew,
          forceSync,
          name: name,
          description,
          deploymentType,
          registryAuthToken: authToken,
          // Flatten the array to contain files to sync where each nested input
          // array has a group of files with the same sha but different path
          files: Array.prototype.concat.apply([], Array.from(this._files).map(([sha, { data, names }]) => {
            return names.map((n) => {
              return {
                sha,
                size: data.length,
                file: toRelative(n, this._path)
              };
            });
          })),
          engines
        }
      });
      if (this._debug) console.timeEnd('> [debug] /now/create');

      // no retry on 4xx
      let body;
      try {
        body = await res.json();
      } catch (err) {
        throw new Error('Unexpected response');
      }

      if (429 === res.status) {
        return bail(responseError(res));
      } else if (res.status >= 400 && res.status < 500) {
        const err = new Error(body.error.message);
        err.userError = true;
        return bail(err);
      } else if (200 !== res.status) {
        throw new Error(body.error.message);
      }

      return body;
    });

    // we report about files whose sizes are too big
    let missingVersion = false;
    if (deployment.warnings) {
      let sizeExceeded = 0;
      deployment.warnings.forEach((warning) => {
        if ('size_limit_exceeded' === warning.reason) {
          const { sha, limit } = warning;
          const n = hashes.get(sha).names.pop();
          console.error('> \u001b[31mWarning!\u001b[39m Skipping file %s (size exceeded %s)',
            n,
            bytes(limit)
          );
          hashes.get(sha).names.unshift(n); // move name (hack, if duplicate matches we report them in order)
          sizeExceeded++;
        } else if ('node_version_not_found' === warning.reason) {
          const { wanted, used } = warning;
          console.error('> \u001b[31mWarning!\u001b[39m Requested node version %s is not available',
            wanted,
            used
          );
          missingVersion = true;
        }
      });

      if (sizeExceeded) {
        console.error(`> \u001b[31mWarning!\u001b[39m ${sizeExceeded} of the files ` +
          'exceeded the limit for your plan.\n' +
          `> See ${chalk.underline('https://zeit.co/account')} to upgrade.`);
      }
    }

    if (!quiet && deployment.nodeVersion) {
      if (engines && engines.node) {
        if (missingVersion) {
          console.log(`> Using Node.js ${chalk.bold(deployment.nodeVersion)} (default)`);
        } else {
          console.log(`> Using Node.js ${chalk.bold(deployment.nodeVersion)} (requested: ${chalk.dim(`\`${engines.node}\``)})`);
        }
      } else {
        console.log(`> Using Node.js ${chalk.bold(deployment.nodeVersion)} (default)`);
      }
    }

    this._id = deployment.deploymentId;
    this._host = deployment.url;
    this._missing = deployment.missing || [];

    return this._url;
  }

  upload () {
    const parts = splitArray(this._missing, MAX_CONCURRENT);

    if (this._debug) {
      console.log('> [debug] Will upload ' +
        `${this._missing.length} files in ${parts.length} ` +
        `steps of ${MAX_CONCURRENT} uploads.`);
    }

    const uploadChunk = () => {
      Promise.all(parts.shift().map((sha) => retry(async (bail, attempt) => {
        const file = this._files.get(sha);
        const { data, names } = file;

        if (this._debug) console.time(`> [debug] /sync #${attempt} ${names.join(' ')}`);
        const stream = resumer().queue(data).end();
        const res = await this._fetch('/now/sync', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/octet-stream',
            'Content-Length': data.length,
            'x-now-deployment-id': this._id,
            'x-now-sha': sha,
            'x-now-file': names.map((name) => toRelative(name, this._path)).join(','),
            'x-now-size': data.length
          },
          body: stream
        });
        if (this._debug) console.timeEnd(`> [debug] /sync #${attempt} ${names.join(' ')}`);

        // no retry on 4xx
        if (200 !== res.status && (400 <= res.status || 500 > res.status)) {
          if (this._debug) console.log('> [debug] bailing on creating due to %s', res.status);
          return bail(responseError(res));
        }

        this.emit('upload', file);
      }, { retries: 3, randomize: true, onRetry: this._onRetry })))
      .then(() => parts.length ? uploadChunk() : this.emit('complete'))
      .catch((err) => this.emit('error', err));
    };

    uploadChunk();
  }

  async listSecrets () {
    return this.retry(async (bail, attempt) => {
      if (this._debug) console.time(`> [debug] #${attempt} GET /secrets`);
      const res = await this._fetch('/now/secrets');
      if (this._debug) console.timeEnd(`> [debug] #${attempt} GET /secrets`);
      const body = await res.json();
      return body.secrets;
    });
  }

  async list (app) {
    const query = app ? `?app=${encodeURIComponent(app)}` : '';

    const { deployments } = await this.retry(async (bail) => {
      if (this._debug) console.time('> [debug] /list');
      const res = await this._fetch('/now/list' + query);
      if (this._debug) console.timeEnd('> [debug] /list');

      // no retry on 4xx
      if (400 <= res.status && 500 > res.status) {
        if (this._debug) {
          console.log('> [debug] bailing on listing due to %s', res.status);
        }
        return bail(responseError(res));
      }

      if (200 !== res.status) {
        throw new Error('Fetching deployment list failed');
      }

      return res.json();
    }, { retries: 3, minTimeout: 2500, onRetry: this._onRetry });

    return deployments;
  }

  async listAliases (deploymentId) {
    return this.retry(async (bail, attempt) => {
      const res = await this._fetch(deploymentId
        ? `/now/deployments/${deploymentId}/aliases`
        : '/now/aliases');
      const body = await res.json();
      return body.aliases;
    });
  }

  getNameservers (domain) {
    return new Promise((resolve, reject) => {
      let fallback = false;

      this.retry(async (bail, attempt) => {
        if (this._debug) console.time(`> [debug] #${attempt} GET /whois-ns${fallback ? '-fallback' : ''}`);
        const res = await this._fetch(`/whois-ns${fallback ? '-fallback' : ''}?domain=${encodeURIComponent(domain)}`);
        if (this._debug) console.timeEnd(`> [debug] #${attempt} GET /whois-ns${fallback ? '-fallback' : ''}`);
        const body = await res.json();
        if (200 === res.status) {
          if ((!body.nameservers || body.nameservers.length === 0) && !fallback) {
            // if the nameservers are `null` it's likely
            // that our whois service failed to parse it
            fallback = true;
            throw new Error('Invalid whois response');
          }

          return body;
        } else {
        if (attempt > 1) fallback = true;
          throw new Error(`Whois error (${res.status}): ${body.error.message}`);
        }
      }).then((body) => {
        body.nameservers = body.nameservers.filter((ns) => {
          // temporary hack:
          // sometimes we get a response that looks like:
          // ['ns', 'ns', '', '']
          // so we filter the empty ones
          return ns.length;
        });
        resolve(body);
      });
    });
  }

  // _ensures_ the domain is setup (idempotent)
  setupDomain (name, { isExternal } = {}) {
    return this.retry(async (bail, attempt) => {
      if (this._debug) console.time(`> [debug] #${attempt} POST /domains`);
      const res = await this._fetch('/domains', {
        method: 'POST',
        body: { name, isExternal: !!isExternal }
      });
      if (this._debug) console.timeEnd(`> [debug] #${attempt} POST /domains`);

      if (403 === res.status) {
        const body = await res.json();
        const code = body.error.code;
        let err;

        if ('custom_domain_needs_upgrade' === code) {
          err = new Error(`Custom domains are only enabled for premium accounts. Please upgrade at ${chalk.underline('https://zeit.co/account')}.`);
        } else {
          err = new Error(`Not authorized to access domain ${name}`);
        }

        err.userError = true;
        return bail(err);
      }

      const body = await res.json();

      // domain already exists
      if (409 === res.status) {
        if (this._debug) console.log('> [debug] Domain already exists (noop)');
        return { uid: body.error.uid };
      }

      if (200 !== res.status) {
        throw new Error(body.error.message);
      }

      return body;
    });
  }

  async remove (deploymentId, { hard }) {
    const data = { deploymentId, hard };

    await this.retry(async (bail) => {
      if (this._debug) console.time('> [debug] /remove');
      const res = await this._fetch('/now/remove', {
        method: 'DELETE',
        body: data
      });
      if (this._debug) console.timeEnd('> [debug] /remove');

      // no retry on 4xx
      if (400 <= res.status && 500 > res.status) {
        if (this._debug) {
          console.log('> [debug] bailing on removal due to %s', res.status);
        }
        return bail(responseError(res));
      }

      if (200 !== res.status) {
        throw new Error('Removing deployment failed');
      }
    });

    return true;
  }

  retry (fn, { retries = 3, maxTimeout = Infinity } = {}) {
    return retry(fn, {
      retries,
      maxTimeout,
      onRetry: this._onRetry
    });
  }

  _onRetry (err) {
    if (this._debug) {
      console.log(`> [debug] Retrying: ${err.stack}`);
    }
  }

  close () {
    this._agent.close();
  }

  get id () {
    return this._id;
  }

  get url () {
    return `https://${this._host}`;
  }

  get host () {
    return this._host;
  }

  get syncAmount () {
    if (!this._syncAmount) {
      this._syncAmount = this._missing
      .map((sha) => this._files.get(sha).data.length)
      .reduce((a, b) => a + b, 0);
    }
    return this._syncAmount;
  }

  _fetch (_url, opts = {}) {
    opts.headers = opts.headers || {};
    opts.headers.authorization = `Bearer ${this._token}`;
    opts.headers['user-agent'] = ua;
    return this._agent.fetch(_url, opts);
  }
}

function toRelative (path, base) {
  const fullBase = base.endsWith(SEP) ? base : base + SEP;
  let relative = path.substr(fullBase.length);
  if (relative.startsWith(SEP)) relative = relative.substr(1);
  return relative.replace(/\\/g, '/');
}

function responseError (res) {
  const err = new Error('Response error');
  err.status = res.status;

  if (429 === res.status) {
    const retryAfter = res.headers.get('Retry-After');
    if (retryAfter) {
      err.retryAfter = parseInt(retryAfter, 10);
    }
  }

  return err;
}