import { EOL } from 'node:os';
import test from 'ava';
import sinon from 'sinon';
import sh from 'shelljs';
import Git from '../lib/plugin/git/Git.js';
import { factory } from './util/index.js';
import { mkTmpDir, readFile, gitAdd } from './util/helpers.js';

test.beforeEach(() => {
  const tmp = mkTmpDir();
  sh.pushd('-q', tmp);
});

test.serial('should return whether repo has upstream branch', async t => {
  const gitClient = factory(Git);
  sh.exec('git init');
  gitAdd('line', 'file', 'Add file');
  t.false(await gitClient.hasUpstreamBranch());
});

test.serial('should return branch name', async t => {
  const gitClient = factory(Git);
  sh.exec('git init');
  t.is(await gitClient.getBranchName(), null);
  sh.exec('git checkout -b feat');
  gitAdd('line', 'file', 'Add file');
  t.is(await gitClient.getBranchName(), 'feat');
});

test.serial('should return whether tag exists and if working dir is clean', async t => {
  const gitClient = factory(Git);
  sh.exec('git init');
  t.false(await gitClient.tagExists('1.0.0'));
  sh.touch('file');
  t.false(await gitClient.isWorkingDirClean());
  gitAdd('line', 'file', 'Add file');
  sh.exec('git tag 1.0.0');
  t.true(await gitClient.tagExists('1.0.0'));
  t.true(await gitClient.isWorkingDirClean());
});

test.serial('should throw if tag exists', async t => {
  const gitClient = factory(Git);
  sh.exec('git init');
  sh.touch('file');
  gitAdd('line', 'file', 'Add file');
  sh.exec('git tag 0.0.2');
  gitClient.config.setContext({ latestTag: '0.0.1', tagName: '0.0.2' });
  const expected = { instanceOf: Error, message: /fatal: tag '0\.0\.2' already exists/ };
  await t.throwsAsync(gitClient.tag({ name: '0.0.2' }), expected);
});

test.serial('should only warn if tag exists intentionally', async t => {
  const gitClient = factory(Git);
  const { warn } = gitClient.log;
  sh.exec('git init');
  sh.touch('file');
  gitAdd('line', 'file', 'Add file');
  sh.exec('git tag 1.0.0');
  gitClient.config.setContext({ latestTag: '1.0.0', tagName: '1.0.0' });
  await t.notThrowsAsync(gitClient.tag());
  t.is(warn.callCount, 1);
  t.is(warn.firstCall.args[0], 'Tag "1.0.0" already exists');
});

test.serial('should return the remote url', async t => {
  sh.exec(`git init`);
  {
    const options = { git: { pushRepo: 'origin' } };
    const gitClient = factory(Git, { options });
    t.is(await gitClient.getRemoteUrl(), null);
    sh.exec(`git remote add origin foo`);
    t.is(await gitClient.getRemoteUrl(), 'foo');
  }
  {
    const options = { git: { pushRepo: 'another' } };
    const gitClient = factory(Git, { options });
    t.is(await gitClient.getRemoteUrl(), null);
    sh.exec(`git remote add another bar`);
    t.is(await gitClient.getRemoteUrl(), 'bar');
  }
  {
    const options = { git: { pushRepo: 'git://github.com/webpro/release-it.git' } };
    const gitClient = factory(Git, { options });
    t.is(await gitClient.getRemoteUrl(), 'git://github.com/webpro/release-it.git');
  }
});

test.serial('should return the non-origin remote', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  gitAdd('line', 'file', 'Add file');
  sh.exec('git remote rename origin upstream');
  const gitClient = factory(Git);
  t.is(await gitClient.getRemoteUrl(), bare);
});

test.serial('should stage, commit, tag and push', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  const version = '1.2.3';
  gitAdd(`{"version":"${version}"}`, 'package.json', 'Add package.json');
  {
    const gitClient = factory(Git);
    sh.exec(`git tag ${version}`);
    t.is(await gitClient.getLatestTagName(), version);
  }
  {
    const gitClient = factory(Git);
    gitAdd('line', 'file', 'Add file');
    sh.exec('npm --no-git-tag-version version patch');
    await gitClient.stage('package.json');
    await gitClient.commit({ message: `Release v1.2.4` });
    await gitClient.tag({ name: 'v1.2.4', annotation: 'Release v1.2.4' });
    t.is(await gitClient.getLatestTagName(), 'v1.2.4');
    await gitClient.push();
    const status = sh.exec('git status -uno');
    t.true(status.includes('nothing to commit'));
  }
});

test.serial('should commit, tag and push with extra args', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  gitAdd('line', 'file', 'Add file');
  const options = { git: { commitArgs: '-S', tagArgs: ['-T', 'foo'], pushArgs: ['-U', 'bar', '-V'] } };
  const gitClient = factory(Git, { options });
  const stub = sinon.stub(gitClient.shell, 'exec').resolves();
  await gitClient.stage('package.json');
  await gitClient.commit({ message: `Release v1.2.4` });
  await gitClient.tag({ name: 'v1.2.4', annotation: 'Release v1.2.4' });
  await gitClient.push();
  t.true(stub.secondCall.args[0].includes('-S'));
  t.is(stub.thirdCall.args[0][5], '-T');
  t.is(stub.thirdCall.args[0][6], 'foo');
  t.true(stub.lastCall.args[0].join(' ').includes('-U bar -V'));
  stub.restore();
});

test.serial('should amend commit without message if not provided', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  gitAdd('line', 'file', 'Add file');
  const options = { git: { commitArgs: ['--amend', '--no-edit', '--no-verify'] } };
  const gitClient = factory(Git, { options });
  const stub = sinon.stub(gitClient.shell, 'exec').resolves();
  await gitClient.stage('package.json');
  await gitClient.commit();
  t.deepEqual(stub.secondCall.args[0], ['git', 'commit', '--amend', '--no-edit', '--no-verify']);
  stub.restore();
});

test.serial('should commit and tag with quoted characters', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  const gitClient = factory(Git, {
    options: { git: { commitMessage: 'Release ${version}', tagAnnotation: 'Release ${version}\n\n${changelog}' } }
  });
  sh.touch('file');
  const changelog = `- Foo's${EOL}- "$bar"${EOL}- '$baz'${EOL}- foo`;
  gitClient.config.setContext({ version: '1.0.0', changelog });

  await gitClient.stage('file');
  await gitClient.commit();
  await gitClient.tag({ name: '1.0.0' });
  await gitClient.push();
  {
    const { stdout } = sh.exec('git log -1 --format=%s');
    t.is(stdout.trim(), 'Release 1.0.0');
  }
  {
    const { stdout } = sh.exec('git tag -n99');
    t.is(stdout.trim(), `1.0.0           Release 1.0.0\n    \n    - Foo's\n    - "$bar"\n    - '$baz'\n    - foo`);
  }
});

test.serial('should push to origin', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  gitAdd('line', 'file', 'Add file');
  const gitClient = factory(Git);
  const spy = sinon.spy(gitClient.shell, 'exec');
  await gitClient.push();
  t.deepEqual(spy.lastCall.args[0], ['git', 'push']);
  const actual = sh.exec('git ls-tree -r HEAD --name-only', { cwd: bare });
  t.is(actual.trim(), 'file');
  spy.restore();
});

test.serial('should push to tracked upstream branch', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  sh.exec(`git remote rename origin upstream`);
  gitAdd('line', 'file', 'Add file');
  const gitClient = factory(Git);
  const spy = sinon.spy(gitClient.shell, 'exec');
  await gitClient.push();
  t.deepEqual(spy.lastCall.args[0], ['git', 'push']);
  const actual = sh.exec('git ls-tree -r HEAD --name-only', { cwd: bare });
  t.is(actual.trim(), 'file');
  spy.restore();
});

test.serial('should push to repo url', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  gitAdd('line', 'file', 'Add file');
  const options = { git: { pushRepo: 'https://host/repo.git' } };
  const gitClient = factory(Git, { options });
  const spy = sinon.spy(gitClient.shell, 'exec');
  try {
    await gitClient.push();
  } catch (err) {
    t.deepEqual(spy.lastCall.args[0], ['git', 'push', 'https://host/repo.git']);
  }
  spy.restore();
});

test.serial('should push to remote name (not "origin")', async t => {
  const bare = mkTmpDir();
  sh.exec(`git init --bare ${bare}`);
  sh.exec(`git clone ${bare} .`);
  gitAdd('line', 'file', 'Add file');
  sh.exec(`git remote add upstream ${sh.exec('git config --get remote.origin.url')}`);
  const options = { git: { pushRepo: 'upstream' } };
  const gitClient = factory(Git, { options });
  const spy = sinon.spy(gitClient.shell, 'exec');
  await gitClient.push();
  t.deepEqual(spy.lastCall.args[0], ['git', 'push', 'upstream']);
  const actual = sh.exec('git ls-tree -r HEAD --name-only', { cwd: bare });
  t.is(actual.trim(), 'file');
  {
    sh.exec(`git checkout -b foo`);
    gitAdd('line', 'file', 'Add file');
    await gitClient.push();
    t.deepEqual(spy.lastCall.args[0], ['git', 'push', '--set-upstream', 'upstream', 'foo']);
    t.regex(
      await spy.lastCall.returnValue,
      /branch .?foo.? set up to track (remote branch .?foo.? from .?upstream.?|.?upstream\/foo.?)/i
    );
  }
  spy.restore();
});

test.serial('should return repo status', async t => {
  const gitClient = factory(Git);
  sh.exec('git init');
  gitAdd('line', 'file1', 'Add file');
  sh.ShellString('line').toEnd('file1');
  sh.ShellString('line').toEnd('file2');
  sh.exec('git add file2');
  t.is(await gitClient.status(), ' M file1\nA  file2');
});

test.serial('should reset files', async t => {
  const gitClient = factory(Git);
  sh.exec('git init');
  gitAdd('line', 'file', 'Add file');
  sh.ShellString('line').toEnd('file');
  t.regex(await readFile('file'), /^line\s*line\s*$/);
  await gitClient.reset('file');
  t.regex(await readFile('file'), /^line\s*$/);
  await gitClient.reset(['file2, file3']);
  t.regex(gitClient.log.warn.firstCall.args[0], /Could not reset file2, file3/);
});

test.serial('should roll back when cancelled', async t => {
  sh.exec('git init');
  sh.exec(`git remote add origin file://foo`);
  const version = '1.2.3';
  gitAdd(`{"version":"${version}"}`, 'package.json', 'Add package.json');
  const options = { git: { requireCleanWorkingDir: true, commit: true, tag: true, tagName: 'v${version}' } };
  const gitClient = factory(Git, { options });
  const exec = sinon.spy(gitClient.shell, 'execFormattedCommand');
  sh.exec(`git tag ${version}`);
  gitAdd('line', 'file', 'Add file');

  await gitClient.init();

  sh.exec('npm --no-git-tag-version version patch');

  gitClient.bump('1.2.4');
  await gitClient.beforeRelease();
  await gitClient.stage('package.json');
  await gitClient.commit({ message: 'Add this' });
  await gitClient.tag();
  await gitClient.rollbackOnce();

  t.is(exec.args[11][0], 'git tag --delete v1.2.4');
  t.is(exec.args[12][0], 'git reset --hard HEAD~1');
});

test.serial('should not touch existing history when rolling back', async t => {
  sh.exec('git init');
  const version = '1.2.3';
  gitAdd(`{"version":"${version}"}`, 'package.json', 'Add package.json');
  const options = { git: { requireCleanWorkingDir: true, commit: true, tag: true } };
  const gitClient = factory(Git, { options });
  sh.exec(`git tag ${version}`);

  const exec = sinon.spy(gitClient.shell, 'execFormattedCommand');
  gitClient.config.setContext({ version: '1.2.4' });
  await gitClient.beforeRelease();
  await gitClient.commit();
  await gitClient.rollbackOnce();

  t.is(exec.args[3][0], 'git reset --hard HEAD');
});

test.serial('should not roll back with risky config', async t => {
  sh.exec('git init');
  const options = { git: { requireCleanWorkingDir: false, commit: true, tag: true } };
  const gitClient = factory(Git, { options });
  await gitClient.beforeRelease();
  t.is('rollbackOnce' in gitClient, false);
});
