/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import {
  describe,
  it,
  expect,
  vi,
  beforeEach,
  afterEach,
  type Mock,
} from 'vitest';
import {
  GitService,
  SHADOW_REPO_AUTHOR_NAME,
  SHADOW_REPO_AUTHOR_EMAIL,
} from './gitService.js';
import { Storage } from '../config/storage.js';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js';
import { spawnAsync } from '../utils/shell-utils.js';

const PROJECT_SLUG = 'project-slug';

vi.mock('../utils/shell-utils.js', () => ({
  spawnAsync: vi.fn(),
}));

const hoistedMockEnv = vi.hoisted(() => vi.fn());
const hoistedMockSimpleGit = vi.hoisted(() => vi.fn());
const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn());
const hoistedMockInit = vi.hoisted(() => vi.fn());
const hoistedMockRaw = vi.hoisted(() => vi.fn());
const hoistedMockAdd = vi.hoisted(() => vi.fn());
const hoistedMockCommit = vi.hoisted(() => vi.fn());
const hoistedMockStatus = vi.hoisted(() => vi.fn());
vi.mock('simple-git', () => ({
  simpleGit: hoistedMockSimpleGit.mockImplementation(() => ({
    checkIsRepo: hoistedMockCheckIsRepo,
    init: hoistedMockInit,
    raw: hoistedMockRaw,
    add: hoistedMockAdd,
    commit: hoistedMockCommit,
    status: hoistedMockStatus,
    env: hoistedMockEnv,
  })),
  CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' },
}));

const hoistedIsGitRepositoryMock = vi.hoisted(() => vi.fn());
vi.mock('../utils/gitUtils.js', () => ({
  isGitRepository: hoistedIsGitRepositoryMock,
}));

const hoistedMockHomedir = vi.hoisted(() => vi.fn());
vi.mock('node:os', async (importOriginal) => {
  const actual = await importOriginal<typeof os>();
  return {
    ...actual,
    homedir: hoistedMockHomedir,
  };
});

vi.mock('../utils/paths.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../utils/paths.js')>();
  return {
    ...actual,
    homedir: vi.fn(),
  };
});

const hoistedMockDebugLogger = vi.hoisted(() => ({
  debug: vi.fn(),
  warn: vi.fn(),
  error: vi.fn(),
}));
vi.mock('../utils/debugLogger.js', () => ({
  debugLogger: hoistedMockDebugLogger,
}));

describe('GitService', () => {
  let testRootDir: string;
  let projectRoot: string;
  let homedir: string;
  let storage: Storage;

  beforeEach(async () => {
    testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-service-test-'));
    projectRoot = path.join(testRootDir, 'project');
    homedir = path.join(testRootDir, 'home');
    await fs.mkdir(projectRoot, { recursive: true });
    await fs.mkdir(homedir, { recursive: true });

    vi.clearAllMocks();
    hoistedIsGitRepositoryMock.mockReturnValue(true);
    (spawnAsync as Mock).mockResolvedValue({
      stdout: 'git version 2.0.0',
      stderr: '',
    });

    hoistedMockHomedir.mockReturnValue(homedir);
    (pathsHomedir as Mock).mockReturnValue(homedir);

    hoistedMockEnv.mockImplementation(() => ({
      checkIsRepo: hoistedMockCheckIsRepo,
      init: hoistedMockInit,
      raw: hoistedMockRaw,
      add: hoistedMockAdd,
      commit: hoistedMockCommit,
      status: hoistedMockStatus,
    }));
    hoistedMockSimpleGit.mockImplementation(() => ({
      checkIsRepo: hoistedMockCheckIsRepo,
      init: hoistedMockInit,
      raw: hoistedMockRaw,
      add: hoistedMockAdd,
      commit: hoistedMockCommit,
      status: hoistedMockStatus,
      env: hoistedMockEnv,
    }));
    hoistedMockCheckIsRepo.mockResolvedValue(false);
    hoistedMockInit.mockResolvedValue(undefined);
    hoistedMockRaw.mockResolvedValue('');
    hoistedMockAdd.mockResolvedValue(undefined);
    hoistedMockCommit.mockResolvedValue({
      commit: 'initial',
    });
    storage = new Storage(projectRoot);
  });

  afterEach(async () => {
    vi.restoreAllMocks();
    await fs.rm(testRootDir, { recursive: true, force: true });
  });

  describe('constructor', () => {
    it('should successfully create an instance', () => {
      expect(() => new GitService(projectRoot, storage)).not.toThrow();
    });
  });

  describe('verifyGitAvailability', () => {
    it('should resolve true if git --version command succeeds', async () => {
      await expect(GitService.verifyGitAvailability()).resolves.toBe(true);
      expect(spawnAsync).toHaveBeenCalledWith('git', ['--version']);
    });

    it('should resolve false if git --version command fails', async () => {
      (spawnAsync as Mock).mockRejectedValue(new Error('git not found'));
      await expect(GitService.verifyGitAvailability()).resolves.toBe(false);
    });
  });

  describe('initialize', () => {
    it('should throw an error if Git is not available', async () => {
      (spawnAsync as Mock).mockRejectedValue(new Error('git not found'));
      const service = new GitService(projectRoot, storage);
      await expect(service.initialize()).rejects.toThrow(
        'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
      );
    });

    it('should call setupShadowGitRepository if Git is available', async () => {
      const service = new GitService(projectRoot, storage);
      const setupSpy = vi
        .spyOn(service, 'setupShadowGitRepository')
        .mockResolvedValue(undefined);

      await service.initialize();
      expect(setupSpy).toHaveBeenCalled();
    });
  });

  describe('setupShadowGitRepository', () => {
    let repoDir: string;
    let gitConfigPath: string;

    beforeEach(async () => {
      repoDir = path.join(homedir, GEMINI_DIR, 'history', PROJECT_SLUG);
      gitConfigPath = path.join(repoDir, '.gitconfig');
    });

    it('should create history and repository directories', async () => {
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();
      const stats = await fs.stat(repoDir);
      expect(stats.isDirectory()).toBe(true);
    });

    it('should create a .gitconfig file with the correct content', async () => {
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();

      const expectedConfigContent = `[user]\n  name = ${SHADOW_REPO_AUTHOR_NAME}\n  email = ${SHADOW_REPO_AUTHOR_EMAIL}\n[commit]\n  gpgsign = false\n`;
      const actualConfigContent = await fs.readFile(gitConfigPath, 'utf-8');
      expect(actualConfigContent).toBe(expectedConfigContent);
    });

    it('should initialize git repo in historyDir if not already initialized', async () => {
      hoistedMockCheckIsRepo.mockResolvedValue(false);
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();
      expect(hoistedMockSimpleGit).toHaveBeenCalledWith(
        repoDir,
        expect.anything(),
      );
      expect(hoistedMockInit).toHaveBeenCalled();
    });

    it('should not initialize git repo if already initialized', async () => {
      hoistedMockCheckIsRepo.mockResolvedValue(true);
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();
      expect(hoistedMockInit).not.toHaveBeenCalled();
    });

    it('should copy .gitignore from projectRoot if it exists', async () => {
      const gitignoreContent = 'node_modules/\n.env';
      const visibleGitIgnorePath = path.join(projectRoot, '.gitignore');
      await fs.writeFile(visibleGitIgnorePath, gitignoreContent);

      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();

      const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
      const copiedContent = await fs.readFile(hiddenGitIgnorePath, 'utf-8');
      expect(copiedContent).toBe(gitignoreContent);
    });

    it('should not create a .gitignore in shadow repo if project .gitignore does not exist', async () => {
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();

      const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
      // An empty string is written if the file doesn't exist.
      const content = await fs.readFile(hiddenGitIgnorePath, 'utf-8');
      expect(content).toBe('');
    });

    it('should throw an error if reading projectRoot .gitignore fails with other errors', async () => {
      const visibleGitIgnorePath = path.join(projectRoot, '.gitignore');
      // Create a directory instead of a file to cause a read error
      await fs.mkdir(visibleGitIgnorePath);

      const service = new GitService(projectRoot, storage);
      // EISDIR is the expected error code on Unix-like systems
      await expect(service.setupShadowGitRepository()).rejects.toThrow(
        /EISDIR: illegal operation on a directory, read|EBUSY: resource busy or locked, read/,
      );
    });

    it('should make an initial commit if no commits exist in history repo', async () => {
      hoistedMockCheckIsRepo.mockResolvedValue(false);
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();
      expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit', {
        '--allow-empty': null,
      });
    });

    it('should not make an initial commit if commits already exist', async () => {
      hoistedMockCheckIsRepo.mockResolvedValue(true);
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();
      expect(hoistedMockCommit).not.toHaveBeenCalled();
    });

    it('should handle checkIsRepo failure gracefully and initialize repo', async () => {
      // Simulate checkIsRepo failing (e.g., on certain Git versions like macOS 2.39.5)
      hoistedMockCheckIsRepo.mockRejectedValue(
        new Error('git rev-parse --is-inside-work-tree failed'),
      );
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();
      // Should proceed to initialize the repo since checkIsRepo failed
      expect(hoistedMockInit).toHaveBeenCalled();
      // Should log the error using debugLogger
      expect(hoistedMockDebugLogger.debug).toHaveBeenCalledWith(
        expect.stringContaining('checkIsRepo failed'),
      );
    });

    it('should configure git environment to use local gitconfig', async () => {
      hoistedMockCheckIsRepo.mockResolvedValue(false);
      const service = new GitService(projectRoot, storage);
      await service.setupShadowGitRepository();

      expect(hoistedMockEnv).toHaveBeenCalledWith(
        expect.objectContaining({
          GIT_CONFIG_GLOBAL: gitConfigPath,
          GIT_CONFIG_SYSTEM: path.join(repoDir, '.gitconfig_system_empty'),
          GIT_AUTHOR_NAME: SHADOW_REPO_AUTHOR_NAME,
          GIT_AUTHOR_EMAIL: SHADOW_REPO_AUTHOR_EMAIL,
          GIT_COMMITTER_NAME: SHADOW_REPO_AUTHOR_NAME,
          GIT_COMMITTER_EMAIL: SHADOW_REPO_AUTHOR_EMAIL,
        }),
      );

      const systemConfigContent = await fs.readFile(
        path.join(repoDir, '.gitconfig_system_empty'),
        'utf-8',
      );
      expect(systemConfigContent).toBe('');
    });

    describe('environment variable preservation', () => {
      const customPath = '/custom/bin';
      const safeHome = '/home/user';
      const sensitiveKey = 'sk-123456789';

      beforeEach(() => {
        vi.stubEnv('PATH', customPath);
        vi.stubEnv('HOME', safeHome);
        vi.stubEnv('API_KEY', sensitiveKey);
        vi.stubEnv('UNRELATED_VAR', 'some-value');
        // Explicitly unset strict mode triggers to ensure predictable test behavior
        // across local and CI environments.
        vi.stubEnv('GITHUB_SHA', '');
        vi.stubEnv('SURFACE', '');
        hoistedMockCheckIsRepo.mockResolvedValue(false);
      });

      afterEach(() => {
        vi.unstubAllEnvs();
      });

      it('should preserve system PATH in the Git environment', async () => {
        const service = new GitService(projectRoot, storage);
        await service.setupShadowGitRepository();

        expect(hoistedMockEnv).toHaveBeenCalledWith(
          expect.objectContaining({
            PATH: customPath,
            GIT_CONFIG_GLOBAL: expect.any(String),
            GIT_AUTHOR_NAME: SHADOW_REPO_AUTHOR_NAME,
          }),
        );
      });

      it('should preserve safe environment variables like HOME', async () => {
        const service = new GitService(projectRoot, storage);
        await service.setupShadowGitRepository();

        expect(hoistedMockEnv).toHaveBeenCalledWith(
          expect.objectContaining({
            HOME: safeHome,
          }),
        );
      });

      it('should NOT include sensitive environment variables like API_KEY', async () => {
        const service = new GitService(projectRoot, storage);
        await service.setupShadowGitRepository();

        const callArgs = hoistedMockEnv.mock.calls[0][0];
        expect(callArgs.API_KEY).toBeUndefined();
      });

      it('should preserve unrelated environment variables in non-strict mode', async () => {
        const service = new GitService(projectRoot, storage);
        await service.setupShadowGitRepository();

        const callArgs = hoistedMockEnv.mock.calls[0][0];
        expect(callArgs.UNRELATED_VAR).toBe('some-value');
      });

      it('should explicitly unset GIT_DIR and GIT_WORK_TREE to maintain isolation', async () => {
        const service = new GitService(projectRoot, storage);
        await service.setupShadowGitRepository();

        const callArgs = hoistedMockEnv.mock.calls[0][0];
        expect(callArgs.GIT_DIR).toBeUndefined();
        expect(callArgs.GIT_WORK_TREE).toBeUndefined();
      });
    });

    describe('GIT_CONFIG isolation', () => {
      beforeEach(() => {
        vi.stubEnv('GIT_CONFIG_GLOBAL', '/user/global/config');
        vi.stubEnv('GIT_CONFIG_SYSTEM', '/user/system/config');
        hoistedMockCheckIsRepo.mockResolvedValue(false);
      });

      afterEach(() => {
        vi.unstubAllEnvs();
      });

      it('should override GIT_CONFIG environment variables from process.env', async () => {
        const service = new GitService(projectRoot, storage);
        await service.setupShadowGitRepository();

        const expectedConfigPath = path.join(repoDir, '.gitconfig');
        const expectedSystemPath = path.join(
          repoDir,
          '.gitconfig_system_empty',
        );

        expect(hoistedMockEnv).toHaveBeenCalledWith(
          expect.objectContaining({
            GIT_CONFIG_GLOBAL: expectedConfigPath,
            GIT_CONFIG_SYSTEM: expectedSystemPath,
          }),
        );

        // Ensure it's not using the values from stubbed process.env
        const callArgs = hoistedMockEnv.mock.calls[0][0];
        expect(callArgs.GIT_CONFIG_GLOBAL).not.toBe('/user/global/config');
        expect(callArgs.GIT_CONFIG_SYSTEM).not.toBe('/user/system/config');
      });
    });

    describe('shadowGitRepository prioritization', () => {
      beforeEach(() => {
        vi.stubEnv('GIT_DIR', '/user/fake/.git');
        vi.stubEnv('GIT_WORK_TREE', '/user/fake/worktree');
        hoistedMockCheckIsRepo.mockResolvedValue(true);
      });

      afterEach(() => {
        vi.unstubAllEnvs();
      });

      it('should prioritize internal GIT_DIR and GIT_WORK_TREE over process.env', async () => {
        const service = new GitService(projectRoot, storage);
        // Trigger a call to shadowGitRepository (e.g., via getCurrentCommitHash)
        hoistedMockRaw.mockResolvedValue('hash');
        await service.getCurrentCommitHash();

        const expectedRepoDir = storage.getHistoryDir();
        const expectedGitDir = path.join(expectedRepoDir, '.git');

        expect(hoistedMockEnv).toHaveBeenCalledWith(
          expect.objectContaining({
            GIT_DIR: expectedGitDir,
            GIT_WORK_TREE: projectRoot,
          }),
        );

        // Ensure user env was overridden
        const callArgs = hoistedMockEnv.mock.calls[0][0];
        expect(callArgs.GIT_DIR).not.toBe('/user/fake/.git');
        expect(callArgs.GIT_WORK_TREE).not.toBe('/user/fake/worktree');
      });
    });
  });

  describe('createFileSnapshot', () => {
    it('should commit with --no-verify flag', async () => {
      hoistedMockStatus.mockResolvedValue({ isClean: () => false });
      const service = new GitService(projectRoot, storage);
      await service.initialize();
      await service.createFileSnapshot('test commit');
      expect(hoistedMockCommit).toHaveBeenCalledWith('test commit', {
        '--no-verify': null,
      });
    });

    it('should create a new commit if there are staged changes', async () => {
      hoistedMockStatus.mockResolvedValue({ isClean: () => false });
      hoistedMockCommit.mockResolvedValue({ commit: 'new-commit-hash' });
      const service = new GitService(projectRoot, storage);
      const commitHash = await service.createFileSnapshot('test message');
      expect(hoistedMockAdd).toHaveBeenCalledWith('.');
      expect(hoistedMockStatus).toHaveBeenCalled();
      expect(hoistedMockCommit).toHaveBeenCalledWith('test message', {
        '--no-verify': null,
      });
      expect(commitHash).toBe('new-commit-hash');
    });

    it('should return the current HEAD commit hash if there are no staged changes', async () => {
      hoistedMockStatus.mockResolvedValue({ isClean: () => true });
      hoistedMockRaw.mockResolvedValue('current-head-hash');
      const service = new GitService(projectRoot, storage);
      const commitHash = await service.createFileSnapshot('test message');
      expect(hoistedMockAdd).toHaveBeenCalledWith('.');
      expect(hoistedMockStatus).toHaveBeenCalled();
      expect(hoistedMockCommit).not.toHaveBeenCalled();
      expect(hoistedMockRaw).toHaveBeenCalledWith('rev-parse', 'HEAD');
      expect(commitHash).toBe('current-head-hash');
    });
  });
});
