/*
 * Copyright 2018 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

'use strict';

const fs = require('fs');
const { resolve: resolvePath, join: joinPaths } = require('path');
const { PassThrough } = require('stream');

const fse = require('fs-extra');
const git = require('isomorphic-git');

const { pathExists } = require('./utils');

// cache for isomorphic-git API
// see https://isomorphic-git.org/docs/en/cache
const cache = {};

/**
 * Various helper functions for reading git meta-data and content
 */

/**
 * Returns the name (abbreviated form) of the currently checked out branch.
 *
 * @param {string} dir git repo path
 * @returns {Promise<string>} name of the currently checked out branch
 */
async function currentBranch(dir) {
  return git.currentBranch({ fs, dir, fullname: false });
}

/**
 * Returns the name (abbreviated form) of the default branch.
 *
 * The 'default branch' is a GitHub concept and doesn't exist
 * for local git repositories. This method uses a simple heuristic to
 * determnine the 'default branch' of a local git repository.
 *
 * @param {string} dir git repo path
 * @returns {Promise<string>} name of the default branch
 */
async function defaultBranch(dir) {
  const branches = await git.listBranches({ fs, dir });
  if (branches.includes('main')) {
    return 'main';
  }
  if (branches.includes('master')) {
    return 'master';
  }
  return currentBranch(dir);
}

/**
 * Parses Github url path subsegment `<ref>/<filePath>` (e.g. `main/some/file.txt`
 * or `some/branch/some/file.txt`) and returns an `{ ref, fpath }` object.
 *
 * Issue #53: Handle branch names containing '/' (e.g. 'foo/bar')
 *
 * @param {string} dir git repo path
 * @param {string} refPathName path including reference (branch or tag) and file path
 *                             (e.g. `main/some/file.txt` or `some/branch/some/file.txt`)
 * @returns {Promise<object>} an `{ ref, pathName }` object or `undefined` if the ref cannot
 *                            be resolved to an existing branch or tag.
 */
async function determineRefPathName(dir, refPathName) {
  const branches = await git.listBranches({ fs, dir });
  const tags = await git.listTags({ fs, dir });
  const refs = branches.concat(tags);
  // find matching refs
  const matchingRefs = refs.filter((ref) => refPathName.startsWith(`${ref}/`));
  if (!matchingRefs.length) {
    return undefined;
  }
  // find longest matching ref
  const matchingRef = matchingRefs.reduce((a, b) => ((b.length > a.length) ? b : a));
  return { ref: matchingRef, pathName: refPathName.substr(matchingRef.length) };
}

/**
 * Determines whether the specified reference is currently checked out in the working dir.
 *
 * @param {string} dir git repo path
 * @param {string} ref reference (branch or tag)
 * @returns {Promise<boolean>} `true` if the specified reference is checked out
 */
async function isCheckedOut(dir, ref) {
  let oidCurrent;
  return git.resolveRef({ fs, dir, ref: 'HEAD' })
    .then((oid) => {
      oidCurrent = oid;
      return git.resolveRef({ fs, dir, ref });
    })
    .then((oid) => oidCurrent === oid)
    .catch(() => false);
}

/**
 * Returns the commit oid of the curent commit referenced by `ref`
 *
 * @param {string} dir git repo path
 * @param {string} ref reference (branch, tag or commit sha)
 * @returns {Promise<string>} commit oid of the curent commit referenced by `ref`
 * @throws {NotFoundError}: invalid reference
 */
async function resolveCommit(dir, ref) {
  return git.resolveRef({ fs, dir, ref })
    .catch(async (err) => {
      if (err instanceof git.Errors.NotFoundError) {
        // fallback: is ref a shortened oid prefix?
        const oid = await git.expandOid({
          fs, dir, oid: ref, cache,
        }).catch(() => { throw err; });
        return git.resolveRef({ fs, dir, ref: oid });
      }
      // re-throw
      throw err;
    });
}

/**
 * Returns the blob oid of the file at revision `ref` and `pathName`
 *
 * @param {string} dir git repo path
 * @param {string} ref reference (branch, tag or commit sha)
 * @param {string} pathName relative path to file
 * @param {boolean} includeUncommitted include uncommitted changes in working dir
 * @returns {Promise<string>} blob oid of specified file
 * @throws {NotFoundError}: resource not found or invalid reference
 */
async function resolveBlob(dir, ref, pathName, includeUncommitted) {
  const commitSha = await resolveCommit(dir, ref);

  // project-helix/#150: check for uncommitted local changes
  // project-helix/#183: serve newly created uncommitted files
  // project-helix/#187: only serve uncommitted content if currently
  //                     checked-out and requested refs match

  if (!includeUncommitted) {
    return (await git.readObject({
      fs, dir, oid: commitSha, filepath: pathName, cache,
    })).oid;
  }
  // check working dir status
  const status = await git.status({
    fs, dir, filepath: pathName, cache,
  });
  if (status.endsWith('unmodified')) {
    return (await git.readObject({
      fs, dir, oid: commitSha, filepath: pathName,
    })).oid;
  }
  if (status.endsWith('absent') || status.endsWith('deleted')) {
    throw new git.Errors.NotFoundError(pathName);
  }
  // temporary workaround for https://github.com/isomorphic-git/isomorphic-git/issues/752
  // => remove once isomorphic-git #752 is fixed
  if (status.endsWith('added') && !await pathExists(dir, pathName)) {
    throw new git.Errors.NotFoundError(pathName);
  }
  try {
    // return blob id representing working dir file
    const content = await fse.readFile(resolvePath(dir, pathName));
    return git.writeBlob({
      fs,
      dir,
      blob: content,
    });
  } catch (e) {
    // should all errors cause a NotFound ?
    if (e.code === 'ENOENT' && status === 'ignored') {
      throw new git.Errors.NotFoundError(pathName);
    }
    throw e;
  }
}

/**
 * Returns the contents of the file at revision `ref` and `pathName`
 *
 * @param {string} dir git repo path
 * @param {string} ref reference (branch, tag or commit sha)
 * @param {string} pathName relative path to file
 * @param {boolean} includeUncommitted include uncommitted changes in working dir
 * @returns {Promise<Buffer>} content of specified file
 * @throws {NotFoundError}: resource not found or invalid reference
 */
async function getRawContent(dir, ref, pathName, includeUncommitted) {
  return resolveBlob(dir, ref, pathName, includeUncommitted)
    .then(async (oid) => (await git.readObject({
      fs, dir, oid, format: 'content', cache,
    })).object);
}

/**
 * Returns a stream for reading the specified blob.
 *
 * @param {string} dir git repo path
 * @param {string} oid blob sha1
 * @returns {Promise<Stream>} readable Stream instance
 */
async function createBlobReadStream(dir, oid) {
  const { object: content } = await git.readObject({
    fs, dir, oid, cache,
  });
  const stream = new PassThrough();
  stream.end(content);
  return stream;
}

/**
 * Retrieves the specified object from the loose object store.
 *
 * @param {string} dir git repo path
 * @param {string} oid object id
 * @returns {Promise<Object>} object identified by `oid`
 */
async function getObject(dir, oid) {
  return git.readObject({
    fs, dir, oid, cache,
  });
}

/**
 * Resolves oid to a tree and then returns the object at that filepath.
 * To return the root directory of a tree set filepath to ''.
 *
 * @param {string} dir git repo path
 * @param {string} oid object id
 * @param {string} pathName relative path to tree/blob
 * @returns {Promise<Object>} object identified by `oid`
 */
async function resolveObject(dir, oid, pathName) {
  return git.readObject({
    fs, dir, oid, filepath: pathName, cache,
  });
}

/**
 * Checks if the specified string is a valid SHA-1 value.
 *
 * @param {string} str
 * @returns {boolean} `true` if `str` represents a valid SHA-1, otherwise `false`
 */
function isValidSha(str) {
  if (typeof str === 'string' && str.length === 40) {
    const res = str.match(/[0-9a-f]/g);
    return res && res.length === 40;
  }
  return false;
}

/**
 * Returns the tree object identified directly by its sha
 * or indirectly via reference (branch, tag or commit sha)
 *
 * @param {string} dir git repo path
 * @param {string} refOrSha either tree sha or reference (branch, tag or commit sha)
 * @returns {Promise<Object>} tree object
 * @throws {NotFoundError}: not found or invalid reference
 */
async function resolveTree(dir, refOrSha) {
  let oid;
  if (isValidSha(refOrSha)) {
    oid = refOrSha;
  } else {
    // not a full sha: ref or shortened oid prefix?
    try {
      oid = await git.resolveRef({ fs, dir, ref: refOrSha });
    } catch (err) {
      if (err instanceof git.Errors.NotFoundError) {
        // fallback: is ref a shortened oid prefix?
        oid = await git.expandOid({
          fs, dir, oid: refOrSha, cache,
        }).catch(() => { throw err; });
      } else {
        // re-throw
        throw err;
      }
    }
  }

  // resolved oid
  return git.readObject({
    fs, dir, oid, cache,
  })
    .then((obj) => {
      if (obj.type === 'tree') {
        return obj;
      }
      if (obj.type === 'commit') {
        return git.readObject({
          fs, dir, oid: obj.object.tree, cache,
        });
      }
      if (obj.type === 'tag') {
        if (obj.object.type === 'commit') {
          return git.readObject({
            fs, dir, oid: obj.object.object, cache,
          })
            .then((commit) => git.readObject({
              fs, dir, oid: commit.object.tree, cache,
            }));
        }
        if (obj.object.type === 'tree') {
          return git.readObject({
            fs, dir, oid: obj.object.object, cache,
          });
        }
      }
      throw new git.Errors.ObjectTypeError(oid, 'tree|commit|tag', obj.type);
    });
}

/**
 * Returns a commit log, i.e. an array of commits in reverse chronological order.
 *
 * @param {string} dir git repo path
 * @param {string} ref reference (branch, tag or commit sha)
 * @param {string} path only commits containing this file path will be returned
 * @throws {NotFoundError}: not found or invalid reference
 */
async function commitLog(dir, ref, path) {
  return git.log({
    fs, dir, ref, path, cache,
  })
    .catch(async (err) => {
      if (err instanceof git.Errors.NotFoundError) {
        // fallback: is ref a shortened oid prefix?
        const oid = await git.expandOid({ fs, dir, oid: ref }).catch(() => { throw err; });
        return git.log({
          fs, dir, ref: oid, path, cache,
        });
      }
      // re-throw
      throw err;
    })
    .then(async (commits) => {
      if (typeof path === 'string' && path.length) {
        // filter by path
        let lastSHA = null;
        let lastCommit = null;
        const filteredCommits = [];
        for (let i = 0; i < commits.length; i += 1) {
          const c = commits[i];
          /* eslint-disable no-await-in-loop */
          try {
            const o = await git.readObject({
              fs, dir, oid: c.oid, filepath: path, cache,
            });
            if (i === commits.length - 1) {
              // file already existed in first commit
              filteredCommits.push(c);
              break;
            }
            if (o.oid !== lastSHA) {
              if (lastSHA !== null) {
                filteredCommits.push(lastCommit);
              }
              lastSHA = o.oid;
            }
          } catch (err) {
            if (lastCommit) {
              // file no longer there
              filteredCommits.push(lastCommit);
            }
            break;
          }
          lastCommit = c;
        }
        // filtered commits
        return filteredCommits.map((c) => ({ oid: c.oid, ...c.commit }));
      }
      // unfiltered commits
      return commits.map((c) => ({ oid: c.oid, ...c.commit }));
    });
}

/**
 * Recursively collects all tree entries (blobs and trees).
 *
 * @param {string} repPath git repository path
 * @param {Array<object>} entries git tree entries to process
 * @param {Array<object>} result array where tree entries will be collected
 * @param {string} treePath path of specified tree (will be prepended to child entries)
 * @param {boolean} deep recurse into subtrees?
 * @returns {Promise<Array<object>>} collected entries
 */
async function collectTreeEntries(repPath, entries, result, treePath, deep = true) {
  const items = await Promise.all(entries.map(async ({
    oid, type, mode, path,
  }) => ({
    oid, type, mode, path: joinPaths(treePath, path),
  })));
  result.push(...items);
  if (deep) {
    // recurse into subtrees
    const treeItems = items.filter((item) => item.type === 'tree');
    for (let i = 0; i < treeItems.length; i += 1) {
      const { oid, path } = treeItems[i];
      /* eslint-disable no-await-in-loop */
      const { object: subTreeEntries } = await getObject(repPath, oid);
      await collectTreeEntries(repPath, subTreeEntries, result, path, deep);
    }
  }
  return result;
}

module.exports = {
  currentBranch,
  defaultBranch,
  getRawContent,
  resolveTree,
  resolveCommit,
  resolveBlob,
  isCheckedOut,
  createBlobReadStream,
  getObject,
  resolveObject,
  isValidSha,
  commitLog,
  determineRefPathName,
  collectTreeEntries,
  NotFoundError: git.Errors.NotFoundError,
  ObjectTypeError: git.Errors.ObjectTypeError,
};
