/**
 * @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';
import * as os from 'node:os';
import * as path from 'node:path';
import { WorkspaceContext } from './workspaceContext.js';
import { debugLogger } from './debugLogger.js';

describe('WorkspaceContext with real filesystem', () => {
  let tempDir: string;
  let cwd: string;
  let otherDir: string;

  beforeEach(() => {
    // os.tmpdir() can return a path using a symlink (this is standard on macOS)
    // Use fs.realpathSync to fully resolve the absolute path.
    tempDir = fs.realpathSync(
      fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-context-test-')),
    );

    cwd = path.join(tempDir, 'project');
    otherDir = path.join(tempDir, 'other-project');

    fs.mkdirSync(cwd, { recursive: true });
    fs.mkdirSync(otherDir, { recursive: true });
  });

  afterEach(() => {
    fs.rmSync(tempDir, { recursive: true, force: true });
  });

  describe('initialization', () => {
    it('should initialize with a single directory (cwd)', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const directories = workspaceContext.getDirectories();

      expect(directories).toEqual([cwd]);
    });

    it('should validate and resolve directories to absolute paths', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const directories = workspaceContext.getDirectories();

      expect(directories).toEqual([cwd, otherDir]);
    });

    it('should handle empty initialization', () => {
      const workspaceContext = new WorkspaceContext(cwd, []);
      const directories = workspaceContext.getDirectories();
      expect(directories).toHaveLength(1);
      expect(fs.realpathSync(directories[0])).toBe(cwd);
    });
  });

  describe('adding directories', () => {
    it('should add valid directories', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      workspaceContext.addDirectory(otherDir);
      const directories = workspaceContext.getDirectories();

      expect(directories).toEqual([cwd, otherDir]);
    });

    it('should resolve relative paths to absolute', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const relativePath = path.relative(cwd, otherDir);
      workspaceContext.addDirectory(relativePath);
      const directories = workspaceContext.getDirectories();

      expect(directories).toEqual([cwd, otherDir]);
    });

    it('should prevent duplicate directories', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      workspaceContext.addDirectory(otherDir);
      workspaceContext.addDirectory(otherDir);
      const directories = workspaceContext.getDirectories();

      expect(directories).toHaveLength(2);
    });

    it.skipIf(os.platform() === 'win32')(
      'should handle symbolic links correctly',
      () => {
        const realDir = path.join(tempDir, 'real');
        fs.mkdirSync(realDir, { recursive: true });
        const symlinkDir = path.join(tempDir, 'symlink-to-real');
        fs.symlinkSync(realDir, symlinkDir, 'dir');
        const workspaceContext = new WorkspaceContext(cwd);
        workspaceContext.addDirectory(symlinkDir);

        const directories = workspaceContext.getDirectories();

        expect(directories).toEqual([cwd, realDir]);
      },
    );
  });

  describe('path validation', () => {
    it('should accept paths within workspace directories', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const validPath1 = path.join(cwd, 'src', 'file.ts');
      const validPath2 = path.join(otherDir, 'lib', 'module.js');

      fs.mkdirSync(path.dirname(validPath1), { recursive: true });
      fs.writeFileSync(validPath1, 'content');
      fs.mkdirSync(path.dirname(validPath2), { recursive: true });
      fs.writeFileSync(validPath2, 'content');

      expect(workspaceContext.isPathWithinWorkspace(validPath1)).toBe(true);
      expect(workspaceContext.isPathWithinWorkspace(validPath2)).toBe(true);
    });

    it('should accept non-existent paths within workspace directories', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const validPath1 = path.join(cwd, 'src', 'file.ts');
      const validPath2 = path.join(otherDir, 'lib', 'module.js');

      expect(workspaceContext.isPathWithinWorkspace(validPath1)).toBe(true);
      expect(workspaceContext.isPathWithinWorkspace(validPath2)).toBe(true);
    });

    it('should reject paths outside workspace', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const invalidPath = path.join(tempDir, 'outside-workspace', 'file.txt');

      expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false);
    });

    it('should reject non-existent paths outside workspace', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const invalidPath = path.join(tempDir, 'outside-workspace', 'file.txt');

      expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false);
    });

    it('should handle nested directories correctly', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const nestedPath = path.join(cwd, 'deeply', 'nested', 'path', 'file.txt');
      expect(workspaceContext.isPathWithinWorkspace(nestedPath)).toBe(true);
    });

    it('should handle edge cases (root, parent references)', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const rootPath = path.parse(tempDir).root;
      const parentPath = path.dirname(cwd);

      expect(workspaceContext.isPathWithinWorkspace(rootPath)).toBe(false);
      expect(workspaceContext.isPathWithinWorkspace(parentPath)).toBe(false);
    });

    it('should handle non-existent paths correctly', () => {
      const workspaceContext = new WorkspaceContext(cwd, [otherDir]);
      const nonExistentPath = path.join(cwd, 'does-not-exist.txt');
      expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe(
        true,
      );
    });

    describe.skipIf(os.platform() === 'win32')('with symbolic link', () => {
      describe('in the workspace', () => {
        let realDir: string;
        let symlinkDir: string;
        beforeEach(() => {
          realDir = path.join(cwd, 'real-dir');
          fs.mkdirSync(realDir, { recursive: true });

          symlinkDir = path.join(cwd, 'symlink-file');
          fs.symlinkSync(realDir, symlinkDir, 'dir');
        });

        it('should accept dir paths', () => {
          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(symlinkDir)).toBe(true);
        });

        it('should accept non-existent paths', () => {
          const filePath = path.join(symlinkDir, 'does-not-exist.txt');

          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(filePath)).toBe(true);
        });

        it('should accept non-existent deep paths', () => {
          const filePath = path.join(symlinkDir, 'deep', 'does-not-exist.txt');

          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(filePath)).toBe(true);
        });
      });

      describe('outside the workspace', () => {
        let realDir: string;
        let symlinkDir: string;
        beforeEach(() => {
          realDir = path.join(tempDir, 'real-dir');
          fs.mkdirSync(realDir, { recursive: true });

          symlinkDir = path.join(cwd, 'symlink-file');
          fs.symlinkSync(realDir, symlinkDir, 'dir');
        });

        it('should reject dir paths', () => {
          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(symlinkDir)).toBe(
            false,
          );
        });

        it('should reject non-existent paths', () => {
          const filePath = path.join(symlinkDir, 'does-not-exist.txt');

          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(filePath)).toBe(false);
        });

        it('should reject non-existent deep paths', () => {
          const filePath = path.join(symlinkDir, 'deep', 'does-not-exist.txt');

          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(filePath)).toBe(false);
        });

        it('should reject partially non-existent deep paths', () => {
          const deepDir = path.join(symlinkDir, 'deep');
          fs.mkdirSync(deepDir, { recursive: true });
          const filePath = path.join(deepDir, 'does-not-exist.txt');

          const workspaceContext = new WorkspaceContext(cwd);

          expect(workspaceContext.isPathWithinWorkspace(filePath)).toBe(false);
        });
      });

      it('should reject symbolic file links outside the workspace', () => {
        const realFile = path.join(tempDir, 'real-file.txt');
        fs.writeFileSync(realFile, 'content');

        const symlinkFile = path.join(cwd, 'symlink-to-real-file');
        fs.symlinkSync(realFile, symlinkFile, 'file');

        const workspaceContext = new WorkspaceContext(cwd);

        expect(workspaceContext.isPathWithinWorkspace(symlinkFile)).toBe(false);
      });

      it('should reject non-existent symbolic file links outside the workspace', () => {
        const realFile = path.join(tempDir, 'real-file.txt');

        const symlinkFile = path.join(cwd, 'symlink-to-real-file');
        fs.symlinkSync(realFile, symlinkFile, 'file');

        const workspaceContext = new WorkspaceContext(cwd);

        expect(workspaceContext.isPathWithinWorkspace(symlinkFile)).toBe(false);
      });

      it('should handle circular symlinks gracefully', () => {
        const workspaceContext = new WorkspaceContext(cwd);
        const linkA = path.join(cwd, 'link-a');
        const linkB = path.join(cwd, 'link-b');
        // Create a circular dependency: linkA -> linkB -> linkA
        fs.symlinkSync(linkB, linkA, 'dir');
        fs.symlinkSync(linkA, linkB, 'dir');

        // fs.realpathSync should throw ELOOP, and isPathWithinWorkspace should
        // handle it gracefully and return false.
        expect(workspaceContext.isPathWithinWorkspace(linkA)).toBe(false);
        expect(workspaceContext.isPathWithinWorkspace(linkB)).toBe(false);
      }, 30000);
    });
  });

  describe('onDirectoriesChanged', () => {
    it('should call listener when adding a directory', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);

      workspaceContext.addDirectory(otherDir);

      expect(listener).toHaveBeenCalledOnce();
    });

    it('should not call listener when adding a duplicate directory', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      workspaceContext.addDirectory(otherDir);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);

      workspaceContext.addDirectory(otherDir);

      expect(listener).not.toHaveBeenCalled();
    });

    it('should call listener when setting different directories', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);

      workspaceContext.setDirectories([otherDir]);

      expect(listener).toHaveBeenCalledOnce();
    });

    it('should not call listener when setting same directories', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);

      workspaceContext.setDirectories([cwd]);

      expect(listener).not.toHaveBeenCalled();
    });

    it('should support multiple listeners', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener1 = vi.fn();
      const listener2 = vi.fn();
      workspaceContext.onDirectoriesChanged(listener1);
      workspaceContext.onDirectoriesChanged(listener2);

      workspaceContext.addDirectory(otherDir);

      expect(listener1).toHaveBeenCalledOnce();
      expect(listener2).toHaveBeenCalledOnce();
    });

    it('should allow unsubscribing a listener', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      const unsubscribe = workspaceContext.onDirectoriesChanged(listener);

      unsubscribe();
      workspaceContext.addDirectory(otherDir);

      expect(listener).not.toHaveBeenCalled();
    });

    it('should not fail if a listener throws an error', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const errorListener = () => {
        throw new Error('test error');
      };
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(errorListener);
      workspaceContext.onDirectoriesChanged(listener);

      expect(() => {
        workspaceContext.addDirectory(otherDir);
      }).not.toThrow();
      expect(listener).toHaveBeenCalledOnce();
    });
  });

  describe('getDirectories', () => {
    it('should return a copy of directories array', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const dirs1 = workspaceContext.getDirectories();
      const dirs2 = workspaceContext.getDirectories();

      expect(dirs1).not.toBe(dirs2);
      expect(dirs1).toEqual(dirs2);
    });
  });

  describe('addDirectories', () => {
    it('should add multiple directories and emit one event', () => {
      const dir3 = path.join(tempDir, 'dir3');
      fs.mkdirSync(dir3);

      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);

      const result = workspaceContext.addDirectories([otherDir, dir3]);

      expect(workspaceContext.getDirectories()).toContain(otherDir);
      expect(workspaceContext.getDirectories()).toContain(dir3);
      expect(listener).toHaveBeenCalledOnce();
      expect(result.added).toHaveLength(2);
      expect(result.failed).toHaveLength(0);
    });

    it('should handle partial failures', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);

      const loggerSpy = vi
        .spyOn(debugLogger, 'warn')
        .mockImplementation(() => {});

      const nonExistent = path.join(tempDir, 'does-not-exist');
      const result = workspaceContext.addDirectories([otherDir, nonExistent]);

      expect(workspaceContext.getDirectories()).toContain(otherDir);
      expect(workspaceContext.getDirectories()).not.toContain(nonExistent);
      expect(listener).toHaveBeenCalledOnce();
      expect(loggerSpy).toHaveBeenCalled();
      expect(result.added).toEqual([otherDir]);
      expect(result.failed).toHaveLength(1);
      expect(result.failed[0].path).toBe(nonExistent);
      expect(result.failed[0].error).toBeDefined();

      loggerSpy.mockRestore();
    });

    it('should not emit event if no directories added', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const listener = vi.fn();
      workspaceContext.onDirectoriesChanged(listener);
      const loggerSpy = vi
        .spyOn(debugLogger, 'warn')
        .mockImplementation(() => {});

      const nonExistent = path.join(tempDir, 'does-not-exist');
      const result = workspaceContext.addDirectories([nonExistent]);

      expect(listener).not.toHaveBeenCalled();
      expect(result.added).toHaveLength(0);
      expect(result.failed).toHaveLength(1);
      loggerSpy.mockRestore();
    });
  });

  describe('addDirectory', () => {
    it('should throw error if directory fails to add', () => {
      const workspaceContext = new WorkspaceContext(cwd);
      const nonExistent = path.join(tempDir, 'does-not-exist');

      expect(() => workspaceContext.addDirectory(nonExistent)).toThrow();
    });
  });
});

describe('WorkspaceContext with optional directories', () => {
  let tempDir: string;
  let cwd: string;
  let existingDir1: string;
  let existingDir2: string;
  let nonExistentDir: string;

  beforeEach(() => {
    tempDir = fs.realpathSync(
      fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-context-optional-')),
    );
    cwd = path.join(tempDir, 'project');
    existingDir1 = path.join(tempDir, 'existing-dir-1');
    existingDir2 = path.join(tempDir, 'existing-dir-2');
    nonExistentDir = path.join(tempDir, 'non-existent-dir');

    fs.mkdirSync(cwd, { recursive: true });
    fs.mkdirSync(existingDir1, { recursive: true });
    fs.mkdirSync(existingDir2, { recursive: true });

    vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
  });

  afterEach(() => {
    fs.rmSync(tempDir, { recursive: true, force: true });
    vi.restoreAllMocks();
  });

  it('should skip a missing optional directory and log a warning', () => {
    const workspaceContext = new WorkspaceContext(cwd, [
      nonExistentDir,
      existingDir1,
    ]);
    const directories = workspaceContext.getDirectories();
    expect(directories).toEqual([cwd, existingDir1]);
    expect(debugLogger.warn).toHaveBeenCalledTimes(1);
    expect(debugLogger.warn).toHaveBeenCalledWith(
      `[WARN] Skipping unreadable directory: ${nonExistentDir} (Directory does not exist: ${nonExistentDir})`,
    );
  });

  it('should include an existing optional directory', () => {
    const workspaceContext = new WorkspaceContext(cwd, [existingDir1]);
    const directories = workspaceContext.getDirectories();
    expect(directories).toEqual([cwd, existingDir1]);
    expect(debugLogger.warn).not.toHaveBeenCalled();
  });

  describe('Security Regression: Case-Insensitive Sensitive Path Blocklist', () => {
    it('should reject sensitive paths like .git, .env, and node_modules case-insensitively, including Windows trailing character and NTFS ADS bypasses', () => {
      const workspaceContext = new WorkspaceContext(cwd);

      const sensitivePaths = [
        path.join(cwd, '.git', 'config'),
        path.join(cwd, '.GIT', 'config'),
        path.join(cwd, '.Git', 'config'),
        path.join(cwd, '.env'),
        path.join(cwd, '.Env'),
        path.join(cwd, '.ENV'),
        path.join(cwd, 'node_modules', 'package', 'index.js'),
        path.join(cwd, 'NODE_MODULES', 'package', 'index.js'),
        // Windows trailing character bypasses
        path.join(cwd, '.git ', 'config'),
        path.join(cwd, '.git.', 'config'),
        path.join(cwd, '.env ', 'config'),
        path.join(cwd, '.env.', 'config'),
        path.join(cwd, 'node_modules ', 'package', 'index.js'),
        // NTFS Alternate Data Stream bypasses
        path.join(cwd, '.git::$DATA', 'config'),
        path.join(cwd, '.env::$DATA'),
        path.join(cwd, 'node_modules::$DATA', 'package', 'index.js'),
      ];

      for (const p of sensitivePaths) {
        expect(workspaceContext.isPathWithinWorkspace(p)).toBe(false);
      }
    });

    it('should allow standard non-sensitive paths', () => {
      const workspaceContext = new WorkspaceContext(cwd);

      const safePaths = [
        path.join(cwd, 'src', 'index.ts'),
        path.join(cwd, '.gitignore'),
        path.join(cwd, '.env.example'),
        path.join(cwd, 'package.json'),
      ];

      for (const p of safePaths) {
        expect(workspaceContext.isPathWithinWorkspace(p)).toBe(true);
      }
    });
  });
});
