import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { commitAndPush, cloneRepo, verifyRemoteAccess, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from '../git.js';
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN } from '../config.js';

// --- helpers ---
function makeTmpWorkspace() {
  const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
  fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
  return ws;
}

function makeActionSource() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
}

// Default stub: all commands succeed, status returns changes
function makeSpawn(overrides = {}) {
  const calls = [];
  const spawn = (cmd, args, opts) => {
    const key = args[0];
    calls.push({ cmd, args, opts });
    if (overrides[key]) return overrides[key](args, opts);
    if (key === 'status') return { status: 0, stdout: 'M .gitea/ai-review/findings.json', stderr: '', error: null };
    if (key === 'commit') return { status: 0, stdout: '[feature-branch abc1234] chore', stderr: '', error: null };
    return { status: 0, stdout: '', stderr: '', error: null };
  };
  spawn.calls = calls;
  return spawn;
}

describe('commitAndPush', () => {
  let workspace;
  let sourceRoot;

  before(() => { workspace = makeTmpWorkspace(); });
  after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
  before(() => { sourceRoot = makeActionSource(); });
  after(() => { fs.rmSync(sourceRoot, { recursive: true, force: true }); });
  beforeEach(() => {
    for (const f of fs.readdirSync(workspace)) {
      if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
    }
  });

  it('does not embed token in any git command argument', async () => {
    const spawn = makeSpawn();
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);

    for (const { args } of spawn.calls) {
      assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
    }
  });

  it('tags auto commits with the bot marker for workflow filtering', async () => {
    const spawn = makeSpawn();
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);

    const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
    assert.ok(commitCall, 'expected git commit to run');
    assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
    assert.ok(commitCall.args.some(arg => arg.includes('[success]')), 'expected commit message to include success outcome');
  });

  it('tags failed reviews with the failure outcome marker', async () => {
    const spawn = makeSpawn();
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot, 'failure');

    const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
    assert.ok(commitCall, 'expected git commit to run');
    assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker');
    assert.ok(commitCall.args.some(arg => arg.includes('[failure]')), 'expected commit message to include failure outcome');
  });

  it('pushes with the comment-token (PAT) so the bot commit can re-trigger the workflow', async () => {
    const spawn = makeSpawn();
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);

    // push 必須帶「優先 PAT」的 token，Gitea 才會為 bot commit 重新發出 synchronize 事件。
    const expectedToken = GITEA_COMMENT_TOKEN || GITEA_TOKEN;
    const pushCall = spawn.calls.find(c => c.args[0] === 'push');
    assert.ok(pushCall, 'expected git push to run');
    assert.equal(pushCall.opts?.env?.GIT_TOKEN, expectedToken, 'push must use GITEA_COMMENT_TOKEN (fallback GITEA_TOKEN)');
  });

  it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
    const spawn = makeSpawn();
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);

    const networkOps = ['fetch', 'push', 'clone'];
    const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
    assert.ok(networkCalls.length > 0, 'expected at least one network git call');

    for (const { args, opts } of networkCalls) {
      assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
      assert.equal(opts.env.GIT_SSL_NO_VERIFY, undefined, `GIT_SSL_NO_VERIFY must not be forced for git ${args[0]}`);
    }
  });

  it('keeps the askpass script present while the network push runs', async () => {
    let askpassExistsAtPush = null;
    const spawn = makeSpawn({
      push: (_args, opts) => {
        askpassExistsAtPush = !!(opts?.env?.GIT_ASKPASS && fs.existsSync(opts.env.GIT_ASKPASS));
        return { status: 0, stdout: '', stderr: '', error: null };
      },
    });
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
    assert.equal(askpassExistsAtPush, true, 'askpass script must still exist when git push runs');
  });

  it('cleans up askpass script after successful run', async () => {
    await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
    const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
    assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
  });

  it('cleans up askpass script even when git fails', async () => {
    const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
    await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot);
    const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
    assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
  });

  it('skips commit when status shows no changes', async () => {
    const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
    await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
    const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
    assert.equal(commitCalled, false, 'commit should not run when there are no changes');
  });

  it('adds only generated review files', async () => {
    const repoDir = path.join(workspace, 'repo');
    fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
    fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
    fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
    fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true });
    fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
    fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
    const spawn = makeSpawn();
    await commitAndPush(workspace, repoDir, spawn, sourceRoot);
    const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
    const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
    assert.ok(generatedAddCall, 'expected git add for generated review files');
    assert.ok(generatedAddCall.args.includes('.gitea/ai-review/findings.json'));
    assert.ok(generatedAddCall.args.includes('.gitea/ai-review/exclusions.json'));
    assert.equal(addCalls.length, 1, 'expected only generated review files to be staged');
  });

  it('does not overwrite or add action source files', async () => {
    const repoDir = path.join(workspace, 'repo');
    const sourceDocPath = path.join(sourceRoot, 'docs/source-only.md');
    const repoDocPath = path.join(repoDir, 'docs/source-only.md');
    const repoConfigPath = path.join(repoDir, 'project-notes.md');
    fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
    fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
    fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');

    fs.mkdirSync(path.dirname(sourceDocPath), { recursive: true });
    fs.mkdirSync(path.dirname(repoDocPath), { recursive: true });
    fs.writeFileSync(sourceDocPath, 'fresh action source doc');
    fs.writeFileSync(repoDocPath, 'existing repo doc');
    fs.writeFileSync(repoConfigPath, 'existing repo notes');

    const spawn = makeSpawn();
    await commitAndPush(workspace, repoDir, spawn, sourceRoot);
    const addedArgs = spawn.calls.filter(c => c.args[0] === 'add').flatMap(c => c.args);

    assert.equal(fs.readFileSync(repoDocPath, 'utf8'), 'existing repo doc');
    assert.equal(fs.readFileSync(repoConfigPath, 'utf8'), 'existing repo notes');
    assert.ok(!addedArgs.includes('docs/source-only.md'));
    assert.ok(!addedArgs.includes('project-notes.md'));
  });

  it('does not throw when git command fails', async () => {
    const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
    await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
  });

  it('logs push failures separately from commit failures', async () => {
    const repoDir = path.join(workspace, 'repo');
    fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
    fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
    fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
    fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true });
    fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
    fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');

    const spawn = makeSpawn({
      push: () => ({ status: 1, stdout: '', stderr: 'remote: error: pre-receive hook declined', error: null }),
    });
    const logs = [];
    const originalLog = console.log;
    const originalWarn = console.warn;
    const capture = (...args) => { logs.push(args.join(' ')); };
    console.log = capture;
    console.warn = capture;

    try {
      await commitAndPush(workspace, repoDir, spawn, sourceRoot);
    } finally {
      console.log = originalLog;
      console.warn = originalWarn;
    }

    assert.ok(logs.some(line => line.includes('Step8 commit 成功但 push 失敗')));
    assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
  });

  it('does not throw and does not mistake a failed push for success (commit still ran, push attempted)', async () => {
    // commit 成功、僅 push 失敗：函式必須吞掉錯誤不中斷上層流程。
    const spawn = makeSpawn({
      push: () => ({ status: 1, stdout: '', stderr: 'fatal: unable to access remote', error: null }),
    });

    await assert.doesNotReject(
      () => commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot),
      'push failure must not crash the pipeline',
    );

    const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
    assert.ok(commitCall, 'commit should still run before the failed push');
    const pushCall = spawn.calls.find(c => c.args[0] === 'push');
    assert.ok(pushCall, 'push should have been attempted');
  });
});

describe('cloneRepo', () => {
  let workspace;

  before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-test-')); });
  after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });

  it('clones repo when repoDir does not exist', () => {
    const spawn = makeSpawn();
    cloneRepo(workspace, spawn);
    const cloneCalled = spawn.calls.some(c => c.args[0] === 'clone');
    assert.ok(cloneCalled, 'expected git clone to be called');
  });

  it('fetches and checks out when repoDir already exists', () => {
    const repoDir = path.join(workspace, 'repo');
    fs.mkdirSync(repoDir, { recursive: true });
    const spawn = makeSpawn();
    cloneRepo(workspace, spawn);
    const cloneCalled = spawn.calls.some(c => c.args[0] === 'clone');
    const fetchCalled = spawn.calls.some(c => c.args[0] === 'fetch');
    assert.ok(!cloneCalled, 'clone should not run when repoDir exists');
    assert.ok(fetchCalled, 'fetch should run when repoDir exists');
  });

  it('does not embed token in any git command argument', () => {
    const spawn = makeSpawn();
    cloneRepo(workspace, spawn);
    for (const { args } of spawn.calls) {
      assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
    }
  });

  it('uses GIT_ASKPASS for network operations', () => {
    const spawn = makeSpawn();
    cloneRepo(workspace, spawn);
    const networkCalls = spawn.calls.filter(c => ['clone', 'fetch'].includes(c.args[0]));
    assert.ok(networkCalls.length > 0, 'expected at least one network git call');
    for (const { args, opts } of networkCalls) {
      assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
      assert.equal(opts.env.GIT_SSL_NO_VERIFY, undefined, `GIT_SSL_NO_VERIFY must not be forced for git ${args[0]}`);
    }
  });

  it('cleans up askpass script after run', () => {
    const spawn = makeSpawn();
    cloneRepo(workspace, spawn);
    const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
    assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
  });

  it('returns repoDir path', () => {
    const spawn = makeSpawn();
    const result = cloneRepo(workspace, spawn);
    assert.equal(result, path.join(workspace, 'repo'));
  });

  it('reads head commit message and detects bot auto commits', () => {
    const spawn = makeSpawn({
      show: () => ({ status: 0, stdout: `chore: update ai-review findings ${BOT_COMMIT_MARKER}\n`, stderr: '', error: null }),
    });

    assert.ok(getHeadCommitMessage(workspace, spawn).includes(BOT_COMMIT_MARKER));
    assert.equal(isBotAutoCommit(workspace, spawn), true);
  });
});

describe('verifyRemoteAccess', () => {
  let workspace;
  before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'git-lsremote-')); });
  after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });

  it('runs git ls-remote with the askpass credential env and reports ok on success', () => {
    const calls = [];
    const spawn = (cmd, args, opts) => {
      calls.push({ cmd, args, opts });
      return { status: 0, stdout: 'abc123\tHEAD', stderr: '', error: null };
    };
    const result = verifyRemoteAccess(workspace, spawn);
    assert.deepEqual(result, { ok: true });
    const lsRemote = calls.find(c => c.args[0] === 'ls-remote');
    assert.ok(lsRemote, 'expected git ls-remote to run');
    assert.ok(lsRemote.opts?.env?.GIT_ASKPASS, 'expected GIT_ASKPASS env for ls-remote');
    assert.equal(lsRemote.opts.env.GIT_SSL_NO_VERIFY, undefined);
  });

  it('does not leak the token in ls-remote args', () => {
    const calls = [];
    const spawn = (cmd, args, opts) => {
      calls.push({ args });
      return { status: 0, stdout: '', stderr: '', error: null };
    };
    verifyRemoteAccess(workspace, spawn);
    for (const { args } of calls) {
      assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
    }
  });

  it('reports failure (not throw) when git ls-remote fails', () => {
    const spawn = () => ({ status: 128, stdout: '', stderr: 'fatal: could not read Username', error: null });
    const result = verifyRemoteAccess(workspace, spawn);
    assert.equal(result.ok, false);
    assert.match(result.error, /could not read Username/);
  });

  it('reports a clear failure when git is not installed', () => {
    const enoent = new Error('spawnSync git ENOENT');
    enoent.code = 'ENOENT';
    const spawn = () => ({ status: null, stdout: '', stderr: '', error: enoent });
    const result = verifyRemoteAccess(workspace, spawn);
    assert.equal(result.ok, false);
    assert.match(result.error, /找不到 git 指令/);
  });

  it('cleans up the askpass script after running', () => {
    verifyRemoteAccess(workspace, () => ({ status: 0, stdout: '', stderr: '', error: null }));
    const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
    assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
  });
});
