import path from 'path';
import { Buffer } from 'buffer';
import 'stream';
import pad from 'pad';
import pako from 'pako';
import createHash from 'sha.js';
import { FileSystem, GitCommit, GitTree } from './models.js';
import { GitObjectManager, GitRefManager } from './managers.js';
import through2 from 'through2';
import listpack from 'git-list-pack';
import peek from 'buffer-peek-stream';
import applyDelta from 'git-apply-delta';
import marky from 'marky';
import pify from 'pify';
import concat from 'simple-concat';
import split2 from 'split2';
import { log } from './utils.js';

/**
 * Read and/or write to the git config file(s)
 * @param {Object} args - Arguments object
 * @param {FSModule} args.fs - The filesystem holding the git repo
 * @param {string} args.dir - The path to the [working tree](index.html#dir-vs-gitdir) directory
 * @param {string} [args.gitdir=path.join(dir, '.git')] - The path to the [git directory](index.html#dir-vs-gitdir)
 * @param {string} args.path -  The key of the git config entry.
 * @param {string} [args.value] - A value to store at that path.
 * @returns {Promise<any>} - Resolves with the config value
 *
 * If no `value` is provided, it does a read.
 * If a `value` is provided, it does a write.
 *
 * @example
 * let repo = {fs, dir: '<@.@>'}
 *
 * // Write config value
 * await git.config({
 *   ...repo,
 *   path: '<@user.name@>',
 *   value: '<@Mr. Test@>'
 * })
 *
 * // Read config value
 * let value = await git.config({
 *   ...repo,
 *   path: '<@user.name@>'
 * })
 * console.log(value)
 */

const types = {
  commit: 0b0010000,
  tree: 0b0100000,
  blob: 0b0110000,
  tag: 0b1000000,
  ofs_delta: 0b1100000,
  ref_delta: 0b1110000

  /**
   *
   * If there were no errors, then there will be no `errors` property.
   * There can be a mix of `ok` messages and `errors` messages.
   *
   * @typedef {Object} PushResponse
   * @property {Array<string>} [ok] - The first item is "unpack" if the overall operation was successful. The remaining items are the names of refs that were updated successfully.
   * @property {Array<string>} [errors] - If the overall operation threw and error, the first item will be "unpack {Overall error message}". The remaining items are individual refs that failed to be updated in the format "{ref name} {error message}".
   */

  /**
   * Push a branch
   *
   * @param {Object} args - Arguments object
   * @param {FSModule} args.fs - The filesystem holding the git repo
   * @param {string} args.dir - The path to the [working tree](index.html#dir-vs-gitdir) directory
   * @param {string} [args.gitdir=path.join(dir, '.git')] - The path to the [git directory](index.html#dir-vs-gitdir)
   * @param {string} [args.ref=undefined] - Which branch to push. By default this is the currently checked out branch of the repository.
   * @param {string} [args.remote='origin'] - If URL is not specified, determines which remote to use.
   * @param {string} [args.url=undefined] - The URL of the remote git server. The default is the value set in the git config for that remote.
   * @param {string} [args.authUsername=undefined] - The username to use with Basic Auth
   * @param {string} [args.authPassword=undefined] - The password to use with Basic Auth
   * @returns {Promise<PushResponse>} - Resolves successfully when push completes with a detailed description of the operation from the server.
   *
   * @example
   * let repo = {fs, dir: '<@.@>'}
   * let pushResponse = await git.push({
   *   ...repo,
   *   remote: '<@origin@>',
   *   ref: '<@master@>',
   *   authUsername: <@process.env.GITHUB_TOKEN@>,
   *   authPassword: <@process.env.GITHUB_TOKEN@>
   * })
   * console.log(pushResponse)
   */
};

/** @ignore */
async function listCommits({
  dir,
  gitdir = path.join(dir, '.git'),
  fs: _fs,
  start,
  finish
}) {
  const fs = new FileSystem(_fs);
  let startingSet = new Set();
  let finishingSet = new Set();
  for (let ref of start) {
    startingSet.add((await GitRefManager.resolve({ fs, gitdir, ref })));
  }
  for (let ref of finish) {
    // We may not have these refs locally so we must try/catch
    try {
      let oid = await GitRefManager.resolve({ fs, gitdir, ref });
      finishingSet.add(oid);
    } catch (err) {}
  }
  let visited /*: Set<string> */ = new Set();

  // Because git commits are named by their hash, there is no
  // way to construct a cycle. Therefore we won't worry about
  // setting a default recursion limit.
  async function walk(oid) {
    visited.add(oid);
    let { type, object } = await GitObjectManager.read({ fs, gitdir, oid });
    if (type !== 'commit') {
      throw new Error(`Expected type commit but type is ${type}`);
    }
    let commit = GitCommit.from(object);
    let parents = commit.headers().parent;
    for (oid of parents) {
      if (!finishingSet.has(oid) && !visited.has(oid)) {
        await walk(oid);
      }
    }
  }

  // Let's go walking!
  for (let oid of startingSet) {
    await walk(oid);
  }
  return visited;
}

/** @ignore */
async function listObjects({
  dir,
  gitdir = path.join(dir, '.git'),
  fs: _fs,
  oids
}) {
  const fs = new FileSystem(_fs);
  let visited /*: Set<string> */ = new Set();

  // We don't do the purest simplest recursion, because we can
  // avoid reading Blob objects entirely since the Tree objects
  // tell us which oids are Blobs and which are Trees.
  async function walk(oid) {
    visited.add(oid);
    let { type, object } = await GitObjectManager.read({ fs, gitdir, oid });
    if (type === 'commit') {
      let commit = GitCommit.from(object);
      let tree = commit.headers().tree;
      await walk(tree);
    } else if (type === 'tree') {
      let tree = GitTree.from(object);
      for (let entry /*: TreeEntry */ of tree) {
        visited.add(entry.oid);
        // only recurse for trees
        if (entry.type === 'tree') {
          await walk(entry.oid);
        }
      }
    }
  }

  // Let's go walking!
  for (let oid of oids) {
    await walk(oid);
  }
  return visited;
}

/** @ignore */
async function pack({
  dir,
  gitdir = path.join(dir, '.git'),
  fs: _fs,
  oids,
  outputStream
}) {
  const fs = new FileSystem(_fs);
  let hash = createHash('sha1');
  function write(chunk, enc) {
    outputStream.write(chunk, enc);
    hash.update(chunk, enc);
  }
  function writeObject({ stype, object }) {
    let lastFour, multibyte, length;
    // Object type is encoded in bits 654
    let type = types[stype];
    if (type === undefined) throw new Error('Unrecognized type: ' + stype);
    // The length encoding get complicated.
    length = object.length;
    // Whether the next byte is part of the variable-length encoded number
    // is encoded in bit 7
    multibyte = length > 0b1111 ? 0b10000000 : 0b0;
    // Last four bits of length is encoded in bits 3210
    lastFour = length & 0b1111;
    // Discard those bits
    length = length >>> 4;
    // The first byte is then (1-bit multibyte?), (3-bit type), (4-bit least sig 4-bits of length)
    let byte = (multibyte | type | lastFour).toString(16);
    write(byte, 'hex');
    // Now we keep chopping away at length 7-bits at a time until its zero,
    // writing out the bytes in what amounts to little-endian order.
    while (multibyte) {
      multibyte = length > 0b01111111 ? 0b10000000 : 0b0;
      byte = multibyte | length & 0b01111111;
      write(pad(2, byte.toString(16), '0'), 'hex');
      length = length >>> 7;
    }
    // Lastly, we can compress and write the object.
    write(Buffer.from(pako.deflate(object)));
  }

  write('PACK');
  write('00000002', 'hex');
  // Write a 4 byte (32-bit) int
  write(pad(8, oids.length.toString(16), '0'), 'hex');
  for (let oid of oids) {
    let { type, object } = await GitObjectManager.read({ fs, gitdir, oid });
    writeObject({ write, object, stype: type });
  }
  // Write SHA1 checksum
  let digest = hash.digest();
  outputStream.end(digest);
  return outputStream;
}

/**
 * Fetch commits
 *
 * @link https://isomorphic-git.github.io/docs/fetch.html
 */


const types$1 = {
  1: 'commit',
  2: 'tree',
  3: 'blob',
  4: 'tag',
  6: 'ofs-delta',
  7: 'ref-delta'
};

function parseVarInt(buffer$$1 /*: Buffer */) {
  let n = 0;
  for (var i = 0; i < buffer$$1.byteLength; i++) {
    n = (buffer$$1[i] & 0b01111111) + (n << 7);
    if ((buffer$$1[i] & 0b10000000) === 0) {
      if (i !== buffer$$1.byteLength - 1) throw new Error('Invalid varint buffer');
      return n;
    }
  }
  throw new Error('Invalid varint buffer');
}

/**
 * @ignore
 * @param {Object} args - Arguments object
 * @param {FSModule} args.fs - The filesystem holding the git repo
 * @param {string} args.dir - The path to the [working tree](index.html#dir-vs-gitdir) directory
 * @param {string} [args.gitdir=path.join(dir, '.git')] - The path to the [git directory](index.html#dir-vs-gitdir)
 * @param {ReadableStream} args.inputStream
 * @param {Function} args.onprogress
 */
async function unpack({
  dir,
  gitdir = path.join(dir, '.git'),
  fs: _fs,
  inputStream,
  emitter,
  onprogress // deprecated
}) {
  if (onprogress !== undefined) {
    console.warn('The `onprogress` callback has been deprecated. Please use the more generic `emitter` EventEmitter argument instead.');
  }
  const fs = new FileSystem(_fs);
  return new Promise(function (resolve, reject) {
    // Read header
    peek(inputStream, 12, (err, data, inputStream) => {
      if (err) return reject(err);
      let iden = data.slice(0, 4).toString('utf8');
      if (iden !== 'PACK') {
        throw new Error(`Packfile started with '${iden}'. Expected 'PACK'`);
      }
      let ver = data.slice(4, 8).toString('hex');
      if (ver !== '00000002') {
        throw new Error(`Unknown packfile version '${ver}'. Expected 00000002.`);
      }
      // Read a 4 byte (32-bit) int
      let numObjects = data.readInt32BE(8);
      if (emitter) {
        emitter.emit('progress', {
          loaded: 0,
          total: numObjects,
          lengthComputable: true
        });
      }
      if (numObjects === 0) return resolve();
      // And on our merry way
      let totalTime = 0;
      let totalApplyDeltaTime = 0;
      let totalWriteFileTime = 0;
      let totalReadFileTime = 0;
      let offsetMap = new Map();
      inputStream.pipe(listpack()).pipe(through2.obj(async ({ data, type, reference, offset, num }, enc, next) => {
        type = types$1[type];
        marky.mark(`${type} #${num} ${data.length}B`);
        if (type === 'ref-delta') {
          let oid = Buffer.from(reference).toString('hex');
          try {
            marky.mark(`readFile`);
            let { object, type } = await GitObjectManager.read({
              fs,
              gitdir,
              oid
            });
            totalReadFileTime += marky.stop(`readFile`).duration;
            marky.mark(`applyDelta`);
            let result = applyDelta(data, object);
            totalApplyDeltaTime += marky.stop(`applyDelta`).duration;
            marky.mark(`writeFile`);
            let newoid = await GitObjectManager.write({
              fs,
              gitdir,
              type,
              object: result
            });
            totalWriteFileTime += marky.stop(`writeFile`).duration;
            // console.log(`${type} ${newoid} ref-delta ${oid}`)
            offsetMap.set(offset, newoid);
          } catch (err) {
            throw new Error(`Could not find object ${reference} ${oid} that is referenced by a ref-delta object in packfile at byte offset ${offset}.`);
          }
        } else if (type === 'ofs-delta') {
          // Note: this might be not working because offsets might not be
          // guaranteed to be on object boundaries? In which case we'd need
          // to write the packfile to disk first, I think.
          // For now I've "solved" it by simply not advertising ofs-delta as a capability
          // during the HTTP request, so Github will only send ref-deltas not ofs-deltas.
          let absoluteOffset = offset - parseVarInt(reference);
          let referenceOid = offsetMap.get(absoluteOffset);
          // console.log(`${offset} ofs-delta ${absoluteOffset} ${referenceOid}`)
          let { type, object } = await GitObjectManager.read({
            fs,
            gitdir,
            oid: referenceOid
          });
          let result = applyDelta(data, object);
          let oid = await GitObjectManager.write({
            fs,
            gitdir,
            type,
            object: result
          });
          // console.log(`${offset} ${type} ${oid} ofs-delta ${referenceOid}`)
          offsetMap.set(offset, oid);
        } else {
          marky.mark(`writeFile`);
          let oid = await GitObjectManager.write({
            fs,
            gitdir,
            type,
            object: data
          });
          totalWriteFileTime += marky.stop(`writeFile`).duration;
          // console.log(`${offset} ${type} ${oid}`)
          offsetMap.set(offset, oid);
        }
        if (emitter) {
          emitter.emit('progress', {
            loaded: numObjects - num,
            total: numObjects,
            lengthComputable: true
          });
        }
        let perfentry = marky.stop(`${type} #${num} ${data.length}B`);
        totalTime += perfentry.duration;
        if (num === 0) {
          log(`Total time unpacking objects: ${totalTime}`);
          log(`Total time applying deltas: ${totalApplyDeltaTime}`);
          log(`Total time reading files: ${totalReadFileTime}`);
          log(`Total time writing files: ${totalWriteFileTime}`);
          return resolve();
        }
        next(null);
      })).on('error', reject).on('finish', resolve);
    });
  });
}

export { listCommits, listObjects, pack, unpack };
