import { FileSystem, GitConfig, GitIndex, GitObject, GitPktLine } from './models.js';
import path from 'path';
import AsyncLock from 'async-lock';
import ignore from 'ignore';
import { Buffer } from 'buffer';
import shasum from 'shasum';
import simpleGet from 'simple-get';
import concat from 'simple-concat';
import pify from 'pify';
import { pkg } from './utils.js';
import { PassThrough } from 'stream';

// @flow
class GitConfigManager {
  static async get({ fs: _fs, gitdir }) {
    const fs = new FileSystem(_fs);
    // We can improve efficiency later if needed.
    // TODO: read from full list of git config files
    let text = await fs.read(`${gitdir}/config`, { encoding: 'utf8' });
    return GitConfig.from(text);
  }
  static async save({ fs: _fs, gitdir, config }) {
    const fs = new FileSystem(_fs);
    // We can improve efficiency later if needed.
    // TODO: handle saving to the correct global/user/repo location
    await fs.write(`${gitdir}/config`, config.toString(), {
      encoding: 'utf8'
    });
  }
}

// @flow
// TODO: Add file locks.
class GitShallowManager {
  static async read({ fs, gitdir }) {
    let oids = new Set();
    let text = await fs.read(path.join(gitdir, 'shallow'), { encoding: 'utf8' });
    if (text === null) return oids;
    text.trim().split('\n').map(oid => oids.add(oid));
    return oids;
  }
  static async write({ fs, gitdir, oids }) {
    let text = '';
    for (let oid of oids) {
      text += `${oid}\n`;
    }
    await fs.write(path.join(gitdir, 'shallow'), text, {
      encoding: 'utf8'
    });
  }
}

// @flow
// import LockManager from 'travix-lock-manager'
// import Lock from '../utils'

// TODO: replace with an LRU cache?
const map /*: Map<string, GitIndex> */ = new Map();
// const lm = new LockManager()
const lock = new AsyncLock();

class GitIndexManager {
  static async acquire({ fs: _fs, filepath }, closure) {
    const fs = new FileSystem(_fs);
    await lock.acquire(filepath, async function () {
      let index = map.get(filepath);
      if (index === undefined) {
        // Acquire a file lock while we're reading the index
        // to make sure other processes aren't writing to it
        // simultaneously, which could result in a corrupted index.
        // const fileLock = await Lock(filepath)
        const rawIndexFile = await fs.read(filepath);
        index = GitIndex.from(rawIndexFile);
        // cache the GitIndex object so we don't need to re-read it
        // every time.
        // TODO: save the stat data for the index so we know whether
        // the cached file is stale (modified by an outside process).
        map.set(filepath, index);
        // await fileLock.cancel()
      }
      await closure(index);
      if (index._dirty) {
        // Acquire a file lock while we're writing the index file
        // let fileLock = await Lock(filepath)
        const buffer$$1 = index.toObject();
        await fs.write(filepath, buffer$$1);
        index._dirty = false;
      }
      // For now, discard our cached object so that external index
      // manipulation is picked up. TODO: use lstat and compare
      // file times to determine if our cached object should be
      // discarded.
      map.delete(filepath);
    });
  }
}

// @flow
// I'm putting this in a Manager because I reckon it could benefit
// from a LOT of cacheing.

// TODO: Implement .git/info/exclude

class GitIgnoreManager {
  static async isIgnored({
    fs: _fs,
    gitdir,
    workdir,
    filepath
  }) /*: Promise<boolean> */{
    const fs = new FileSystem(_fs);
    let pairs = [{
      gitignore: path.join(workdir, '.gitignore'),
      filepath
    }];
    let pieces = filepath.split('/');
    for (let i = 1; i < pieces.length; i++) {
      let dir = pieces.slice(0, i).join('/');
      let file = pieces.slice(i).join('/');
      pairs.push({
        gitignore: path.join(workdir, dir, '.gitignore'),
        filepath: file
      });
    }
    let ignoredStatus = false;
    for (let p of pairs) {
      let file;
      try {
        file = await fs.read(p.gitignore, 'utf8');
      } catch (err) {
        if (err.code === 'NOENT') continue;
      }
      let ign = ignore().add(file);
      let unign = ignore().add(`**\n${file}`);
      // If the parent directory is excluded, we are done.
      // "It is not possible to re-include a file if a parent directory of that file is excluded. Git doesn’t list excluded directories for performance reasons, so any patterns on contained files have no effect, no matter where they are defined."
      // source: https://git-scm.com/docs/gitignore
      let parentdir = path.dirname(p.filepath);
      if (ign.ignores(parentdir)) return true;
      // If the file is currently ignored, test for UNignoring.
      if (ignoredStatus) {
        ignoredStatus = unign.ignores(p.filepath);
      } else {
        ignoredStatus = ign.ignores(p.filepath);
      }
    }
    return ignoredStatus;
  }
}

class GitObjectManager {
  static async read({ fs: _fs, gitdir, oid }) {
    const fs = new FileSystem(_fs);
    let file = await fs.read(`${gitdir}/objects/${oid.slice(0, 2)}/${oid.slice(2)}`);
    if (!file) {
      // Check to see if it's in shallow commits.
      let text = await fs.read(`${gitdir}/shallow`, { encoding: 'utf8' });
      if (text !== null && text.includes(oid)) {
        throw new Error(`Failed to read git object with oid ${oid} because it is a shallow commit`);
      } else {
        throw new Error(`Failed to read git object with oid ${oid}`);
      }
    }
    let { type, object } = GitObject.unwrap({ oid, file });
    return { type, object };
  }

  static async hash({ gitdir, type, object }) /*: Promise<string> */{
    let buffer$$1 = Buffer.concat([Buffer.from(type + ' '), Buffer.from(object.byteLength.toString()), Buffer.from([0]), Buffer.from(object)]);
    let oid = shasum(buffer$$1);
    return oid;
  }

  static async write({ fs: _fs, gitdir, type, object }) /*: Promise<string> */{
    const fs = new FileSystem(_fs);
    let { file, oid } = GitObject.wrap({ type, object });
    let filepath = `${gitdir}/objects/${oid.slice(0, 2)}/${oid.slice(2)}`;
    // Don't overwrite existing git objects - this helps avoid EPERM errors.
    // Although I don't know how we'd fix corrupted objects then. Perhaps delete them
    // on read?
    if (!(await fs.exists(filepath))) await fs.write(filepath, file);
    return oid;
  }
}

// This is a convenience wrapper for reading and writing files in the 'refs' directory.
class GitRefManager {
  /* ::
  updateRemoteRefs : ({
    gitdir: string,
    remote: string,
    refs: Map<string, string>,
    symrefs: Map<string, string>
  }) => Promise<void>
  */
  static async updateRemoteRefs({ fs: _fs, gitdir, remote, refs, symrefs }) {
    const fs = new FileSystem(_fs);
    // Validate input
    for (let value of refs.values()) {
      if (!value.match(/[0-9a-f]{40}/)) {
        throw new Error(`Unexpected ref contents: '${value}'`);
      }
    }
    // Combine refs and symrefs giving symrefs priority
    let actualRefsToWrite = new Map();
    for (let [key, value] of refs) actualRefsToWrite.set(key, value);
    for (let [key, value] of symrefs) {
      let branch = value.replace(/^refs\/heads\//, '');
      actualRefsToWrite.set(key, `ref: refs/remotes/${remote}/${branch}`);
    }
    // Update files
    const normalizeValue = value => value.trim() + '\n';
    for (let [key, value] of actualRefsToWrite) {
      // For some reason we trim these
      key = key.replace(/^refs\/heads\//, '');
      key = key.replace(/^refs\/tags\//, '');
      await fs.write(path.join(gitdir, 'refs', 'remotes', remote, key), normalizeValue(value), 'utf8');
    }
  }
  static async resolve({ fs: _fs, gitdir, ref, depth }) {
    const fs = new FileSystem(_fs);
    if (depth !== undefined) {
      depth--;
      if (depth === -1) {
        return ref;
      }
    }
    let sha;
    // Is it a ref pointer?
    if (ref.startsWith('ref: ')) {
      ref = ref.slice('ref: '.length);
      return GitRefManager.resolve({ fs, gitdir, ref, depth });
    }
    // Is it a complete and valid SHA?
    if (ref.length === 40 && /[0-9a-f]{40}/.test(ref)) {
      return ref;
    }
    // Is it a special ref?
    if (ref === 'HEAD' || ref === 'MERGE_HEAD') {
      sha = await fs.read(`${gitdir}/${ref}`, { encoding: 'utf8' });
      if (sha) {
        return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth });
      }
    }
    // Is it a full ref?
    if (ref.startsWith('refs/')) {
      sha = await fs.read(`${gitdir}/${ref}`, { encoding: 'utf8' });
      if (sha) {
        return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth });
      }
    }
    // Is it a (local) branch?
    sha = await fs.read(`${gitdir}/refs/heads/${ref}`, { encoding: 'utf8' });
    if (sha) {
      return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth });
    }
    // Is it a tag?
    sha = await fs.read(`${gitdir}/refs/tags/${ref}`, { encoding: 'utf8' });
    if (sha) {
      return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth });
    }
    // Is it remote branch?
    sha = await fs.read(`${gitdir}/refs/remotes/${ref}`, { encoding: 'utf8' });
    if (sha) {
      return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth });
    }
    // Is it a packed ref? (This must be last because refs in heads have priority)
    let text = await fs.read(`${gitdir}/packed-refs`, { encoding: 'utf8' });
    if (text && text.includes(ref)) {
      let candidates = text.trim().split('\n').filter(x => x.endsWith(ref)).filter(x => !x.startsWith('#'));
      if (candidates.length > 1) {
        throw new Error(`Could not resolve ambiguous reference ${ref}`);
      } else if (candidates.length === 1) {
        sha = candidates[0].split(' ')[0];
        return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth });
      }
    }
    // Do we give up?
    throw new Error(`Could not resolve reference ${ref}`);
  }
}

// @flow
function basicAuth(auth) {
  return `Basic ${Buffer.from(auth.username + ':' + auth.password).toString('base64')}`;
}

class GitRemoteHTTP {
  /*::
  GIT_URL : string
  refs : Map<string, string>
  symrefs : Map<string, string>
  capabilities : Set<string>
  auth : { username : string, password : string }
  */
  constructor(url /*: string */) {
    // Auto-append the (necessary) .git if it's missing.
    if (!url.endsWith('.git')) url = url += '.git';
    this.GIT_URL = url;
  }
  async preparePull() {
    await this.discover('git-upload-pack');
  }
  async preparePush() {
    await this.discover('git-receive-pack');
  }
  async discover(service /*: string */) {
    this.capabilities = new Set();
    this.refs = new Map();
    this.symrefs = new Map();
    let headers = {};
    // headers['Accept'] = `application/x-${service}-advertisement`
    if (this.auth) {
      headers['Authorization'] = basicAuth(this.auth);
    }
    let res = await pify(simpleGet)({
      method: 'GET',
      url: `${this.GIT_URL}/info/refs?service=${service}`,
      headers
    });
    if (res.statusCode !== 200) {
      throw new Error(`Bad status code from server: ${res.statusCode}`);
    }
    let data = await pify(concat)(res);
    // There is probably a better way to do this, but for now
    // let's just throw the result parser inline here.
    let read = GitPktLine.reader(data);
    let lineOne = read();
    // skip past any flushes
    while (lineOne === null) lineOne = read();
    if (lineOne === true) throw new Error('Bad response from git server.');
    if (lineOne.toString('utf8') !== `# service=${service}\n`) {
      throw new Error(`Expected '# service=${service}\\n' but got '${lineOne.toString('utf8')}'`);
    }
    let lineTwo = read();
    // skip past any flushes
    while (lineTwo === null) lineTwo = read();
    // In the edge case of a brand new repo, zero refs (and zero capabilities)
    // are returned.
    if (lineTwo === true) return;
    let [firstRef, capabilities] = lineTwo.toString('utf8').trim().split('\0');
    capabilities.split(' ').map(x => this.capabilities.add(x));
    let [ref, name] = firstRef.split(' ');
    this.refs.set(name, ref);
    while (true) {
      let line = read();
      if (line === true) break;
      if (line !== null) {
        let [ref, name] = line.toString('utf8').trim().split(' ');
        this.refs.set(name, ref);
      }
    }
    // Symrefs are thrown into the "capabilities" unfortunately.
    for (let cap of this.capabilities) {
      if (cap.startsWith('symref=')) {
        let m = cap.match(/symref=([^:]+):(.*)/);
        if (m.length === 3) {
          this.symrefs.set(m[1], m[2]);
        }
      }
    }
  }
  async push(stream$$1 /*: ReadableStream */) {
    const service = 'git-receive-pack';
    let res = await this.stream({ stream: stream$$1, service });
    return res;
  }
  async pull(stream$$1 /*: ReadableStream */) {
    const service = 'git-upload-pack';
    let res = await this.stream({ stream: stream$$1, service });
    return res;
  }
  async stream({
    stream: stream$$1,
    service
  }) /*: Promise<{ packfile: ReadableStream, progress: ReadableStream }> */{
    let headers = {};
    headers['content-type'] = `application/x-${service}-request`;
    headers['accept'] = `application/x-${service}-result`;
    headers['user-agent'] = `git/${pkg.name}@${pkg.version}`;
    if (this.auth) {
      headers['authorization'] = basicAuth(this.auth);
    }
    let res = await pify(simpleGet)({
      method: 'POST',
      url: `${this.GIT_URL}/${service}`,
      body: stream$$1,
      headers
    });
    // Don't try to parse git pushes for now.
    if (service === 'git-receive-pack') return res;
    // Parse the response!
    let read = GitPktLine.streamReader(res);
    // And now for the ridiculous side-band-64k protocol
    let packetlines = new PassThrough();
    let packfile = new PassThrough();
    let progress = new PassThrough();
    // TODO: Use a proper through stream?
    const nextBit = async function () {
      let line = await read();
      // A made up convention to signal there's no more to read.
      if (line === null) {
        packetlines.end();
        progress.end();
        packfile.end();
        return;
      }
      // Examine first byte to determine which output "stream" to use
      switch (line[0]) {
        case 1:
          // pack data
          packfile.write(line.slice(1));
          break;
        case 2:
          // progress message
          progress.write(line.slice(1));
          break;
        case 3:
          // fatal error message just before stream aborts
          let error = line.slice(1);
          progress.write(error);
          packfile.destroy(new Error(error.toString('utf8')));
          return;
        default:
          // Not part of the side-band-64k protocol
          packetlines.write(line.slice(0));
      }
      // Careful not to blow up the stack.
      // I think Promises in a tail-call position should be OK.
      nextBit();
    };
    nextBit();
    return {
      packetlines,
      packfile,
      progress
    };
  } /*: {
    stream: ReadableStream,
    service: string
    } */
}

export { GitConfigManager, GitShallowManager, GitIndexManager, GitIgnoreManager, GitObjectManager, GitRefManager, GitRemoteHTTP };
