const fetch = require('node-fetch');
const ndjson = require('ndjson');
const byline = require('byline');
const mapStream = require('map-stream');
const serialize = require('../../metadata/serialize');

const DEFAULT_REQUEST_TIMEOUT = 0;

class NDJsonResponse {
  constructor(fetchResponse) {
    this.fetchResponse = fetchResponse;
  }

  stream() {
    return this.fetchResponse.body.pipe(ndjson.parse());
  }

  values() {
    return collectStream(this.stream());
  }
}

class RestError extends Error {
  constructor(response, responseBody) {
    super(response.statusText + '\n' + responseBody);
    this.statusCode = response.status;
    this.response = response;
  }
}

class RestClient {

  constructor(options) {
    this.apiUrl = options.apiUrl || '';
    this.requestTimeout = options.requestTimeout != null ? options.requestTimeout : DEFAULT_REQUEST_TIMEOUT;
  }

  _makeUrl(path) {
    const absPath = path.startsWith('/') ? path : '/' + path;
    return this.apiUrl + absPath;
  }

  req(path, args = {}) {
    const fullUrl = this._makeUrl(path);
    args = Object.assign({ timeout: this.requestTimeout }, args);
    return fetch(fullUrl, args).then(response => {
      if (!response.ok) {
        return response.text().then(responseBody => {
          throw new RestError(response, responseBody);
        });
      }
      return response;
    });
  }

  getRequest(path) {
    return this.req(path);
  }

  postRequest(path, body, isJSON = true) {
    return this.req(path, {
      method: 'POST',
      headers: isJSON ? { 'Content-Type': 'application/json' } : {},
      body: isJSON ? JSON.stringify(body) : body
    });
  }

  id(peerId) {
    let path = 'id';
    if (peerId != null) {
      path += '/' + peerId;
    }
    return this.getRequest(path).then(r => r.json());
  }

  ping(peerId) {
    return this.getRequest(`ping/${ peerId }`).then(parseBoolResponse);
  }

  netPing(peerId) {
    return this.getRequest(`net/ping/${ peerId }`).then(trimTextResponse);
  }

  publish(opts, ...statements) {
    const { namespace, compound } = opts;
    const statementNDJSON = statements.map(s => JSON.stringify(s)).join('\n');

    let path = `publish/${ namespace }`;
    if (compound != null) {
      path += '/' + compound.toString();
    }

    return this.postRequest(path, statementNDJSON, false).then(parseStringArrayResponse);
  }

  statement(statementId) {
    return this.getRequest(`stmt/${ statementId }`).then(r => r.json());
  }

  query(queryString, remotePeer) {
    return this.queryStream(queryString, remotePeer).then(r => r.values());
  }

  queryStream(queryString, remotePeer) {
    let path = 'query';
    if (remotePeer != null) {
      path += '/' + remotePeer;
    }
    return this.postRequest(path, queryString, false).then(r => new NDJsonResponse(r));
  }

  merge(queryString, remotePeer) {
    return this.postRequest(`merge/${ remotePeer }`, queryString, false).then(parseMergeResponse);
  }

  push(queryString, remotePeer) {
    return this.postRequest(`push/${ remotePeer }`, queryString, false).then(parseMergeResponse);
  }

  delete(queryString) {
    return this.postRequest('delete', queryString, false).then(parseIntResponse);
  }

  putData(...objects) {
    const body = objects.filter(o => o != null).map(o => {
      if (o instanceof Buffer) return o;
      try {
        return serialize.encode(o);
      } catch (err) {
        console.error('Serialization error: ' + err.message);
        console.error('Failed Object: ' + JSON.stringify(o, null, 2));
        throw err;
      }
    }).filter(buf => buf.length > 0).map(buf => ({ data: buf.toString('base64') })).map(obj => JSON.stringify(obj)).join('\n');

    return this.postRequest('data/put', body, false).then(parseStringArrayResponse);
  }

  getData(objectId) {
    return this.getRequest(`data/get/${ objectId }`).then(r => r.json()).then(parseDataObjectResponse).then(obj => {
      if (obj == null) {
        // shouldn't happen, because mcnode will return a 404.
        // but we need to check the null from parseDataObjectResponse to make flow happy
        throw new Error(`Object not found for key ${ objectId }`);
      }
      return obj;
    });
  }

  batchGetDataStream(objectIds) {
    return this.postRequest('data/get', objectIds.join('\n'), false).then(r => new NDJsonResponse(r).stream()).then(stream => {
      const outputStream = mapStream((obj, callback) => {
        try {
          callback(null, parseDataObjectResponse(obj));
        } catch (err) {
          callback(err);
        }
      });
      stream.pipe(outputStream);
      return outputStream;
    });
  }

  batchGetData(objectIds) {
    return this.batchGetDataStream(objectIds).then(collectStream);
  }

  getDatastoreKeys() {
    return this.getRequest('data/keys').then(parseStringArrayResponse);
  }

  getDatastoreKeyStream() {
    return this.getRequest('data/keys').then(r => byline(r.body));
  }

  garbageCollectDatastore() {
    return this.postRequest('data/gc', '', false).then(parseIntResponse);
  }

  compactDatastore() {
    return this.postRequest('data/compact', '', false).then(parseBoolResponse);
  }

  syncDatastore() {
    return this.postRequest('data/sync', '', false).then(parseBoolResponse);
  }

  listPeers(namespace, includeSelf = false) {
    let path = 'dir/list';
    if (namespace != null) {
      path = path + '/' + namespace;
      if (includeSelf) {
        path = path + '/all';
      }
    }
    return this.getRequest(path).then(parseStringArrayResponse);
  }

  listNamespaces() {
    return this.getRequest('dir/listns').then(parseStringArrayResponse);
  }

  getAuthorizations() {
    return this.getRequest('auth').then(r => r.json());
  }

  authorize(peerId, namespaces) {
    return this.postRequest(`auth/${ peerId }`, namespaces.join(','), false).then(parseBoolResponse);
  }

  revokeAuthorization(peerId) {
    return this.authorize(peerId, []);
  }

  getStatus() {
    return this.getRequest('status').then(trimTextResponse).then(validateStatus);
  }

  setStatus(status) {
    return this.postRequest(`status/${ status }`, '', false).then(trimTextResponse).then(validateStatus);
  }

  getDirectoryIds() {
    return this.getRequest('config/dir').then(parseStringArrayResponse);
  }

  setDirectoryIds(...ids) {
    return this.postRequest('config/dir', ids.join('\n'), false).then(() => true);
  }

  getInfo() {
    return this.getRequest('config/info').then(trimTextResponse);
  }

  setInfo(info) {
    return this.postRequest('config/info', info, false).then(trimTextResponse);
  }

  getNATConfig() {
    return this.getRequest('config/nat').then(trimTextResponse);
  }

  setNATConfig(config) {
    return this.postRequest('config/nat', config, false).then(parseBoolResponse);
  }

  getNetAddresses(peerId = null) {
    if (peerId != null) {
      return this.getRequest(`net/addr/${ peerId }`).then(parseStringArrayResponse);
    }

    return this.getRequest('net/addr').then(parseStringArrayResponse);
  }

  getNetConnections() {
    return this.getRequest('net/conns').then(parseStringArrayResponse);
  }

  netLookup(peerId) {
    return this.getRequest(`net/lookup/${ peerId }`).then(parseStringArrayResponse);
  }

  netIdentify(peerId) {
    return this.getRequest(`net/identify/${ peerId }`).then(r => r.json());
  }

  getSelfManifest() {
    return this.getRequest('manifest/self').then(trimTextResponse);
  }

  getManifests(peerId = null) {
    let path = peerId == null ? 'manifest' : `manifest/${ peerId }`;

    return this.getRequest(path).then(r => new NDJsonResponse(r)).then(r => r.values());
  }

  setManifests(...manifests) {
    const body = manifests.map(m => JSON.stringify(m)).join('\n');
    return this.postRequest('manifest', body, false).then(parseBoolResponse);
  }

  shutdown() {
    return this.postRequest('shutdown', '', false).then(() => true).catch(err => {
      if (err.errno === 'ECONNRESET') {
        return true; // shutting down the node kills the request socket :)
      }
      throw err;
    });
  }
}

function validateStatus(status) {
  status = status.trim();
  switch (status) {
    case 'online':
    case 'offline':
    case 'public':
      return status;
  }
  throw new Error(`Unknown status: "${ status }"`);
}

function trimTextResponse(response) {
  return response.text().then(s => s.trim());
}

function parseStringArrayResponse(response) {
  return trimTextResponse(response).then(body => body.split('\n').filter(s => s.length > 0));
}

function parseBoolResponse(response) {
  return trimTextResponse(response).then(s => s === 'OK');
}

function parseIntResponse(response) {
  return trimTextResponse(response).then(s => Number.parseInt(s));
}

function parseIntArrayResponse(response) {
  return parseStringArrayResponse(response).then(strings => strings.map(s => Number.parseInt(s)));
}

function parseMergeResponse(response) {
  return parseIntArrayResponse(response).then(counts => {
    const [statementCount, objectCount] = counts;
    return {
      statementCount,
      objectCount
    };
  });
}

function parseDataObjectResponse(obj) {
  if (obj.error !== undefined) {
    return new Error(obj.error);
  }
  if (obj.data == null) {
    return null;
  }

  const data = Buffer.from(obj.data, 'base64');
  try {
    return serialize.decode(data);
  } catch (err) {
    return data;
  }
}

function collectStream(stream) {
  return new Promise((resolve, reject) => {
    const vals = [];
    stream.on('data', val => {
      vals.push(val);
    });
    stream.on('error', reject);
    stream.on('end', () => {
      resolve(vals);
    });
  });
}

module.exports = RestClient;