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

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { FileDiscoveryService } from './fileDiscoveryService.js';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';

describe('FileDiscoveryService', () => {
  let testRootDir: string;
  let projectRoot: string;

  async function createTestFile(filePath: string, content = '') {
    const fullPath = path.join(projectRoot, filePath);
    await fs.mkdir(path.dirname(fullPath), { recursive: true });
    await fs.writeFile(fullPath, content);
    return fullPath;
  }

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

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

  describe('initialization', () => {
    it('should initialize git ignore parser by default in a git repo', async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'node_modules/');

      const service = new FileDiscoveryService(projectRoot);
      // Let's check the effect of the parser instead of mocking it.
      expect(service.shouldIgnoreFile('node_modules/foo.js')).toBe(true);
      expect(service.shouldIgnoreFile('src/foo.js')).toBe(false);
    });

    it('should not load git repo patterns when not in a git repo', async () => {
      // No .git directory
      await createTestFile('.gitignore', 'node_modules/');
      const service = new FileDiscoveryService(projectRoot);

      // .gitignore is not loaded in non-git repos
      expect(service.shouldIgnoreFile('node_modules/foo.js')).toBe(false);
    });

    it('should load .geminiignore patterns even when not in a git repo', async () => {
      await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secrets.txt');
      const service = new FileDiscoveryService(projectRoot);

      expect(service.shouldIgnoreFile('secrets.txt')).toBe(true);
      expect(service.shouldIgnoreFile('src/index.js')).toBe(false);
    });

    it('should call applyFilterFilesOptions in constructor', () => {
      const resolveSpy = vi.spyOn(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        FileDiscoveryService.prototype as any,
        'applyFilterFilesOptions',
      );
      const options = { respectGitIgnore: false };
      new FileDiscoveryService(projectRoot, options);
      expect(resolveSpy).toHaveBeenCalledWith(options);
    });

    it('should correctly resolve options passed to constructor', () => {
      const options = {
        respectGitIgnore: false,
        respectGeminiIgnore: false,
        customIgnoreFilePaths: ['custom/.ignore'],
      };
      const service = new FileDiscoveryService(projectRoot, options);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const defaults = (service as any).defaultFilterFileOptions;

      expect(defaults.respectGitIgnore).toBe(false);
      expect(defaults.respectGeminiIgnore).toBe(false);
      expect(defaults.customIgnoreFilePaths).toStrictEqual(['custom/.ignore']);
    });

    it('should use defaults when options are not provided', () => {
      const service = new FileDiscoveryService(projectRoot);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const defaults = (service as any).defaultFilterFileOptions;

      expect(defaults.respectGitIgnore).toBe(true);
      expect(defaults.respectGeminiIgnore).toBe(true);
      expect(defaults.customIgnoreFilePaths).toStrictEqual([]);
    });

    it('should partially override defaults', () => {
      const service = new FileDiscoveryService(projectRoot, {
        respectGitIgnore: false,
      });
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const defaults = (service as any).defaultFilterFileOptions;

      expect(defaults.respectGitIgnore).toBe(false);
      expect(defaults.respectGeminiIgnore).toBe(true);
    });
  });

  describe('filterFiles', () => {
    beforeEach(async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'node_modules/\n.git/\ndist');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, 'logs/');
    });

    it('should filter out git-ignored and gemini-ignored files by default', () => {
      const files = [
        'src/index.ts',
        'node_modules/package/index.js',
        'README.md',
        '.git/config',
        'dist/bundle.js',
        'logs/latest.log',
      ].map((f) => path.join(projectRoot, f));

      const service = new FileDiscoveryService(projectRoot);

      expect(service.filterFiles(files)).toEqual(
        ['src/index.ts', 'README.md'].map((f) => path.join(projectRoot, f)),
      );
    });

    it('should not filter files when respectGitIgnore is false', () => {
      const files = [
        'src/index.ts',
        'node_modules/package/index.js',
        '.git/config',
        'logs/latest.log',
      ].map((f) => path.join(projectRoot, f));

      const service = new FileDiscoveryService(projectRoot);

      const filtered = service.filterFiles(files, {
        respectGitIgnore: false,
        respectGeminiIgnore: true, // still respect this one
      });

      expect(filtered).toEqual(
        ['src/index.ts', 'node_modules/package/index.js', '.git/config'].map(
          (f) => path.join(projectRoot, f),
        ),
      );
    });

    it('should not filter files when respectGeminiIgnore is false', () => {
      const files = [
        'src/index.ts',
        'node_modules/package/index.js',
        'logs/latest.log',
      ].map((f) => path.join(projectRoot, f));

      const service = new FileDiscoveryService(projectRoot);

      const filtered = service.filterFiles(files, {
        respectGitIgnore: true,
        respectGeminiIgnore: false,
      });

      expect(filtered).toEqual(
        ['src/index.ts', 'logs/latest.log'].map((f) =>
          path.join(projectRoot, f),
        ),
      );
    });

    it('should handle empty file list', () => {
      const service = new FileDiscoveryService(projectRoot);

      expect(service.filterFiles([])).toEqual([]);
    });
  });

  describe('filterFilesWithReport', () => {
    beforeEach(async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'node_modules/');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log');
    });

    it('should return filtered paths and correct ignored count', () => {
      const files = [
        'src/index.ts',
        'node_modules/package/index.js',
        'debug.log',
        'README.md',
      ].map((f) => path.join(projectRoot, f));

      const service = new FileDiscoveryService(projectRoot);
      const report = service.filterFilesWithReport(files);

      expect(report.filteredPaths).toEqual(
        ['src/index.ts', 'README.md'].map((f) => path.join(projectRoot, f)),
      );
      expect(report.ignoredCount).toBe(2);
    });

    it('should handle no ignored files', () => {
      const files = ['src/index.ts', 'README.md'].map((f) =>
        path.join(projectRoot, f),
      );

      const service = new FileDiscoveryService(projectRoot);
      const report = service.filterFilesWithReport(files);

      expect(report.filteredPaths).toEqual(files);
      expect(report.ignoredCount).toBe(0);
    });
  });

  describe('shouldIgnoreFile & shouldIgnoreDirectory', () => {
    beforeEach(async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'node_modules/');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log');
    });

    it('should return true for git-ignored files', () => {
      const service = new FileDiscoveryService(projectRoot);

      expect(
        service.shouldIgnoreFile(
          path.join(projectRoot, 'node_modules/package/index.js'),
        ),
      ).toBe(true);
    });

    it('should return true for git-ignored directories', () => {
      const service = new FileDiscoveryService(projectRoot);
      expect(
        service.shouldIgnoreDirectory(path.join(projectRoot, 'node_modules')),
      ).toBe(true);
    });

    it('should return false for non-git-ignored files', () => {
      const service = new FileDiscoveryService(projectRoot);

      expect(
        service.shouldIgnoreFile(path.join(projectRoot, 'src/index.ts')),
      ).toBe(false);
    });

    it('should return true for gemini-ignored files', () => {
      const service = new FileDiscoveryService(projectRoot);

      expect(
        service.shouldIgnoreFile(path.join(projectRoot, 'debug.log')),
      ).toBe(true);
    });

    it('should return false for non-gemini-ignored files', () => {
      const service = new FileDiscoveryService(projectRoot);

      expect(
        service.shouldIgnoreFile(path.join(projectRoot, 'src/index.ts')),
      ).toBe(false);
    });
  });

  describe('edge cases', () => {
    it('should handle relative project root paths', async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'ignored.txt');
      const service = new FileDiscoveryService(
        path.relative(process.cwd(), projectRoot),
      );

      expect(
        service.shouldIgnoreFile(path.join(projectRoot, 'ignored.txt')),
      ).toBe(true);
      expect(
        service.shouldIgnoreFile(path.join(projectRoot, 'not-ignored.txt')),
      ).toBe(false);
    });

    it('should handle filterFiles with undefined options', async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'ignored.txt');
      const service = new FileDiscoveryService(projectRoot);

      const files = ['src/index.ts', 'ignored.txt'].map((f) =>
        path.join(projectRoot, f),
      );

      expect(service.filterFiles(files, undefined)).toEqual([
        path.join(projectRoot, 'src/index.ts'),
      ]);
    });
  });

  describe('precedence (.geminiignore over .gitignore)', () => {
    beforeEach(async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
    });

    it('should un-ignore a file in .geminiignore that is ignored in .gitignore', async () => {
      await createTestFile('.gitignore', '*.txt');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt');

      const service = new FileDiscoveryService(projectRoot);
      const files = ['file.txt', 'important.txt'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files);
      expect(filtered).toEqual([path.join(projectRoot, 'important.txt')]);
    });

    it('should un-ignore a directory in .geminiignore that is ignored in .gitignore', async () => {
      await createTestFile('.gitignore', 'logs/');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '!logs/');

      const service = new FileDiscoveryService(projectRoot);
      const files = ['logs/app.log', 'other/app.log'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files);
      expect(filtered).toEqual(files);
    });

    it('should extend ignore rules in .geminiignore', async () => {
      await createTestFile('.gitignore', '*.log');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, 'temp/');

      const service = new FileDiscoveryService(projectRoot);
      const files = ['app.log', 'temp/file.txt'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files);
      expect(filtered).toEqual([]);
    });

    it('should use .gitignore rules if respectGeminiIgnore is false', async () => {
      await createTestFile('.gitignore', '*.txt');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt');

      const service = new FileDiscoveryService(projectRoot);
      const files = ['file.txt', 'important.txt'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files, {
        respectGitIgnore: true,
        respectGeminiIgnore: false,
      });

      expect(filtered).toEqual([]);
    });

    it('should use .geminiignore rules if respectGitIgnore is false', async () => {
      await createTestFile('.gitignore', '*.txt');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt\ntemp/');

      const service = new FileDiscoveryService(projectRoot);
      const files = ['file.txt', 'important.txt', 'temp/file.js'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files, {
        respectGitIgnore: false,
        respectGeminiIgnore: true,
      });

      // .gitignore is ignored, so *.txt is not applied.
      // .geminiignore un-ignores important.txt (which wasn't ignored anyway)
      // and ignores temp/
      expect(filtered).toEqual(
        ['file.txt', 'important.txt'].map((f) => path.join(projectRoot, f)),
      );
    });
  });

  describe('custom ignore file', () => {
    it('should respect patterns from a custom ignore file', async () => {
      const customIgnoreName = '.customignore';
      await createTestFile(customIgnoreName, '*.secret');

      const service = new FileDiscoveryService(projectRoot, {
        customIgnoreFilePaths: [customIgnoreName],
      });

      const files = ['file.txt', 'file.secret'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files);
      expect(filtered).toEqual([path.join(projectRoot, 'file.txt')]);
    });

    it('should prioritize custom ignore patterns over .geminiignore patterns in git repo', async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', 'node_modules/');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log');

      const customIgnoreName = '.customignore';
      // .geminiignore ignores *.log, custom un-ignores debug.log
      await createTestFile(customIgnoreName, '!debug.log');

      const service = new FileDiscoveryService(projectRoot, {
        customIgnoreFilePaths: [customIgnoreName],
      });

      const files = ['debug.log', 'error.log'].map((f) =>
        path.join(projectRoot, f),
      );

      const filtered = service.filterFiles(files);
      expect(filtered).toEqual([path.join(projectRoot, 'debug.log')]);
    });

    it('should prioritize custom ignore patterns over .geminiignore patterns in non-git repo', async () => {
      // No .git directory created
      await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secret.txt');

      const customIgnoreName = '.customignore';
      // .geminiignore ignores secret.txt, custom un-ignores it
      await createTestFile(customIgnoreName, '!secret.txt');

      const service = new FileDiscoveryService(projectRoot, {
        customIgnoreFilePaths: [customIgnoreName],
      });

      const files = ['secret.txt'].map((f) => path.join(projectRoot, f));

      const filtered = service.filterFiles(files);
      expect(filtered).toEqual([path.join(projectRoot, 'secret.txt')]);
    });
  });

  describe('getIgnoreFilePaths & getAllIgnoreFilePaths', () => {
    beforeEach(async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
      await createTestFile('.gitignore', '*.log');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.tmp');
      await createTestFile('.customignore', '*.secret');
    });

    it('should return .geminiignore path by default', () => {
      const service = new FileDiscoveryService(projectRoot);
      const paths = service.getIgnoreFilePaths();
      expect(paths).toEqual([path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)]);
    });

    it('should not return .geminiignore path if respectGeminiIgnore is false', () => {
      const service = new FileDiscoveryService(projectRoot, {
        respectGeminiIgnore: false,
      });
      const paths = service.getIgnoreFilePaths();
      expect(paths).toEqual([]);
    });

    it('should return custom ignore file paths', () => {
      const service = new FileDiscoveryService(projectRoot, {
        customIgnoreFilePaths: ['.customignore'],
      });
      const paths = service.getIgnoreFilePaths();
      expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME));
      expect(paths).toContain(path.join(projectRoot, '.customignore'));
    });

    it('should return all ignore paths including .gitignore', () => {
      const service = new FileDiscoveryService(projectRoot);
      const paths = service.getAllIgnoreFilePaths();
      expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME));
      expect(paths).toContain(path.join(projectRoot, '.gitignore'));
    });

    it('should not return .gitignore if respectGitIgnore is false', () => {
      const service = new FileDiscoveryService(projectRoot, {
        respectGitIgnore: false,
      });
      const paths = service.getAllIgnoreFilePaths();
      expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME));
      expect(paths).not.toContain(path.join(projectRoot, '.gitignore'));
    });

    it('should not return .gitignore if it does not exist', async () => {
      await fs.rm(path.join(projectRoot, '.gitignore'));
      const service = new FileDiscoveryService(projectRoot);
      const paths = service.getAllIgnoreFilePaths();
      expect(paths).not.toContain(path.join(projectRoot, '.gitignore'));
      expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME));
    });

    it('should ensure .gitignore is the first file in the list', () => {
      const service = new FileDiscoveryService(projectRoot);
      const paths = service.getAllIgnoreFilePaths();
      expect(paths[0]).toBe(path.join(projectRoot, '.gitignore'));
    });

    it('should exclude directories from getIgnoreFilePaths (#19868)', async () => {
      // Create a directory that shares a name with a customIgnoreFilePaths entry
      await fs.mkdir(path.join(projectRoot, 'node_modules'), {
        recursive: true,
      });

      const service = new FileDiscoveryService(projectRoot, {
        customIgnoreFilePaths: ['node_modules'],
      });
      const paths = service.getIgnoreFilePaths();

      // node_modules/ is a directory, not a file — it should be excluded
      expect(paths).not.toContain(path.join(projectRoot, 'node_modules'));
    });

    it('should exclude directories from getAllIgnoreFilePaths (#19868)', async () => {
      await fs.mkdir(path.join(projectRoot, 'node_modules'), {
        recursive: true,
      });

      const service = new FileDiscoveryService(projectRoot, {
        customIgnoreFilePaths: ['node_modules'],
      });
      const paths = service.getAllIgnoreFilePaths();

      expect(paths).not.toContain(path.join(projectRoot, 'node_modules'));
      // .gitignore should still be present
      expect(paths).toContain(path.join(projectRoot, '.gitignore'));
    });

    it('should not crash when customIgnoreFilePaths contains directory names (#19868)', async () => {
      await fs.mkdir(path.join(projectRoot, 'node_modules'), {
        recursive: true,
      });
      await fs.mkdir(path.join(projectRoot, 'temp'), { recursive: true });

      // This is the exact user scenario from issue #19868
      expect(() => {
        new FileDiscoveryService(projectRoot, {
          customIgnoreFilePaths: ['node_modules/', 'temp/', 'cache/'],
        });
      }).not.toThrow();
    });
  });

  describe('getIgnoredPaths', () => {
    beforeEach(async () => {
      await fs.mkdir(path.join(projectRoot, '.git'));
    });

    it('should return all ignored paths that exist on disk', async () => {
      await createTestFile(
        '.gitignore',
        'ignored-dir/\nignored-file.txt\n*.log',
      );
      await createTestFile('ignored-dir/inside.txt');
      await createTestFile('ignored-file.txt');
      await createTestFile('keep.log');
      await createTestFile('src/index.ts');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secrets/');
      await createTestFile('secrets/passwords.txt');

      const service = new FileDiscoveryService(projectRoot);
      const ignoredPaths = await service.getIgnoredPaths();

      const expectedPaths = [
        path.join(projectRoot, '.git'),
        path.join(projectRoot, 'ignored-dir'),
        path.join(projectRoot, 'ignored-file.txt'),
        path.join(projectRoot, 'keep.log'),
        path.join(projectRoot, 'secrets'),
      ].sort();

      expect(ignoredPaths.sort()).toEqual(expectedPaths);
    });

    it('should optimize by not traversing into ignored directories', async () => {
      await createTestFile('.gitignore', 'ignored-dir/');
      const ignoredDir = path.join(projectRoot, 'ignored-dir');
      await fs.mkdir(ignoredDir);
      await createTestFile('ignored-dir/large-file-1.txt');

      const service = new FileDiscoveryService(projectRoot);
      const ignoredPaths = await service.getIgnoredPaths();

      expect(ignoredPaths.sort()).toEqual(
        [path.join(projectRoot, '.git'), ignoredDir].sort(),
      );
    });

    it('should handle un-ignore patterns correctly', async () => {
      await createTestFile(
        '.gitignore',
        'ignored-dir/*\n!ignored-dir/keep.txt',
      );
      await createTestFile('ignored-dir/ignored.txt');
      await createTestFile('ignored-dir/keep.txt');

      const service = new FileDiscoveryService(projectRoot);
      const ignoredPaths = await service.getIgnoredPaths();

      expect(ignoredPaths).toContain(
        path.join(projectRoot, 'ignored-dir/ignored.txt'),
      );
      expect(ignoredPaths).not.toContain(
        path.join(projectRoot, 'ignored-dir/keep.txt'),
      );
      expect(ignoredPaths).not.toContain(path.join(projectRoot, 'ignored-dir'));
    });

    it('should respect FilterFilesOptions when provided', async () => {
      await createTestFile('.gitignore', 'ignored-by-git.txt');
      await createTestFile(GEMINI_IGNORE_FILE_NAME, 'ignored-by-gemini.txt');
      await createTestFile('ignored-by-git.txt');
      await createTestFile('ignored-by-gemini.txt');

      const service = new FileDiscoveryService(projectRoot);

      const onlyGemini = await service.getIgnoredPaths({
        respectGitIgnore: false,
        respectGeminiIgnore: true,
      });
      expect(onlyGemini).toContain(
        path.join(projectRoot, 'ignored-by-gemini.txt'),
      );
      expect(onlyGemini).not.toContain(
        path.join(projectRoot, 'ignored-by-git.txt'),
      );

      const onlyGit = await service.getIgnoredPaths({
        respectGitIgnore: true,
        respectGeminiIgnore: false,
      });
      expect(onlyGit).toContain(path.join(projectRoot, 'ignored-by-git.txt'));
      expect(onlyGit).not.toContain(
        path.join(projectRoot, 'ignored-by-gemini.txt'),
      );
    });
  });
});
