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

import { describe, it, expect, afterEach, vi } from 'vitest';
import mock from 'mock-fs';
import * as path from 'node:path';
import { WorkspaceContext } from './workspaceContext.js';
import { readPathFromWorkspace } from './pathReader.js';
import type { Config } from '../config/config.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';

// --- Helper for creating a mock Config object ---
// We use the actual implementations of WorkspaceContext and FileSystemService
// to test the integration against mock-fs.
const createMockConfig = (
  cwd: string,
  otherDirs: string[] = [],
  mockFileService?: FileDiscoveryService,
  fileFiltering: {
    respectGitIgnore?: boolean;
    respectGeminiIgnore?: boolean;
  } = {},
): Config => {
  const { respectGitIgnore = true, respectGeminiIgnore = true } = fileFiltering;
  const workspace = new WorkspaceContext(cwd, otherDirs);
  const fileSystemService = new StandardFileSystemService();
  return {
    getWorkspaceContext: () => workspace,
    // TargetDir is used by processSingleFileContent to generate relative paths in errors/output
    getTargetDir: () => cwd,
    getFileSystemService: () => fileSystemService,
    getFileService: () => mockFileService,
    getFileFilteringRespectGitIgnore: () => respectGitIgnore,
    getFileFilteringRespectGeminiIgnore: () => respectGeminiIgnore,
  } as unknown as Config;
};

describe('readPathFromWorkspace', () => {
  const CWD = path.resolve('/test/cwd');
  const OTHER_DIR = path.resolve('/test/other');
  const OUTSIDE_DIR = path.resolve('/test/outside');

  afterEach(() => {
    mock.restore();
    vi.resetAllMocks();
  });

  it('should read a text file from the CWD', async () => {
    mock({
      [CWD]: {
        'file.txt': 'hello from cwd',
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [], mockFileService);
    const result = await readPathFromWorkspace('file.txt', config);
    // Expect [string] for text content
    expect(result).toEqual(['hello from cwd']);
    expect(mockFileService.filterFiles).toHaveBeenCalled();
  });

  it('should read a file from a secondary workspace directory', async () => {
    mock({
      [CWD]: {},
      [OTHER_DIR]: {
        'file.txt': 'hello from other dir',
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [OTHER_DIR], mockFileService);
    const result = await readPathFromWorkspace('file.txt', config);
    expect(result).toEqual(['hello from other dir']);
  });

  it('should prioritize CWD when file exists in both CWD and secondary dir', async () => {
    mock({
      [CWD]: {
        'file.txt': 'hello from cwd',
      },
      [OTHER_DIR]: {
        'file.txt': 'hello from other dir',
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [OTHER_DIR], mockFileService);
    const result = await readPathFromWorkspace('file.txt', config);
    expect(result).toEqual(['hello from cwd']);
  });

  it('should read an image file and return it as inlineData (Part object)', async () => {
    // Use a real PNG header for robustness
    const imageData = Buffer.from([
      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
    ]);
    mock({
      [CWD]: {
        'image.png': imageData,
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [], mockFileService);
    const result = await readPathFromWorkspace('image.png', config);
    // Expect [Part] for image content
    expect(result).toEqual([
      {
        inlineData: {
          mimeType: 'image/png',
          data: imageData.toString('base64'),
        },
      },
    ]);
  });

  it('should read a generic binary file and return an info string', async () => {
    // Data that is clearly binary (null bytes)
    const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03]);
    mock({
      [CWD]: {
        'data.bin': binaryData,
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [], mockFileService);
    const result = await readPathFromWorkspace('data.bin', config);
    // Expect [string] containing the skip message from fileUtils
    expect(result).toEqual(['Cannot display content of binary file: data.bin']);
  });

  it('should read a file from an absolute path if within workspace', async () => {
    const absPath = path.join(OTHER_DIR, 'abs.txt');
    mock({
      [CWD]: {},
      [OTHER_DIR]: {
        'abs.txt': 'absolute content',
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [OTHER_DIR], mockFileService);
    const result = await readPathFromWorkspace(absPath, config);
    expect(result).toEqual(['absolute content']);
  });

  describe('Directory Expansion', () => {
    it('should expand a directory and read the content of its files', async () => {
      mock({
        [CWD]: {
          'my-dir': {
            'file1.txt': 'content of file 1',
            'file2.md': 'content of file 2',
          },
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files) => files),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      const result = await readPathFromWorkspace('my-dir', config);

      // Convert to a single string for easier, order-independent checking
      const resultText = result
        .map((p) => {
          if (typeof p === 'string') return p;
          if (typeof p === 'object' && p && 'text' in p) return p.text;
          // This part is important for handling binary/image data which isn't just text
          if (typeof p === 'object' && p && 'inlineData' in p) return '';
          return p;
        })
        .join('');

      expect(resultText).toContain(
        '--- Start of content for directory: my-dir ---',
      );
      expect(resultText).toContain('--- file1.txt ---');
      expect(resultText).toContain('content of file 1');
      expect(resultText).toContain('--- file2.md ---');
      expect(resultText).toContain('content of file 2');
      expect(resultText).toContain(
        '--- End of content for directory: my-dir ---',
      );
    });

    it('should recursively expand a directory and read all nested files', async () => {
      mock({
        [CWD]: {
          'my-dir': {
            'file1.txt': 'content of file 1',
            'sub-dir': {
              'nested.txt': 'nested content',
            },
          },
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files) => files),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      const result = await readPathFromWorkspace('my-dir', config);

      const resultText = result
        .map((p) => {
          if (typeof p === 'string') return p;
          if (typeof p === 'object' && p && 'text' in p) return p.text;
          return '';
        })
        .join('');

      expect(resultText).toContain('content of file 1');
      expect(resultText).toContain('nested content');
      expect(resultText).toContain(
        `--- ${path.join('sub-dir', 'nested.txt')} ---`,
      );
    });

    it('should handle mixed content and include files from subdirectories', async () => {
      const imageData = Buffer.from([
        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
      ]);
      mock({
        [CWD]: {
          'mixed-dir': {
            'info.txt': 'some text',
            'photo.png': imageData,
            'sub-dir': {
              'nested.txt': 'this should be included',
            },
            'empty-sub-dir': {},
          },
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files) => files),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      const result = await readPathFromWorkspace('mixed-dir', config);

      // Check for the text part
      const textContent = result
        .map((p) => {
          if (typeof p === 'string') return p;
          if (typeof p === 'object' && p && 'text' in p) return p.text;
          return ''; // Ignore non-text parts for this assertion
        })
        .join('');
      expect(textContent).toContain('some text');
      expect(textContent).toContain('this should be included');

      // Check for the image part
      const imagePart = result.find(
        (p) => typeof p === 'object' && 'inlineData' in p,
      );
      expect(imagePart).toEqual({
        inlineData: {
          mimeType: 'image/png',
          data: imageData.toString('base64'),
        },
      });
    });

    it('should handle an empty directory', async () => {
      mock({
        [CWD]: {
          'empty-dir': {},
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files) => files),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      const result = await readPathFromWorkspace('empty-dir', config);
      expect(result).toEqual([
        { text: '--- Start of content for directory: empty-dir ---\n' },
        { text: '--- End of content for directory: empty-dir ---' },
      ]);
    });
  });

  describe('File Ignoring', () => {
    it('should return an empty array for an ignored file', async () => {
      mock({
        [CWD]: {
          'ignored.txt': 'ignored content',
        },
      });
      const mockFileService = {
        filterFiles: vi.fn(() => []), // Simulate the file being filtered out
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      const result = await readPathFromWorkspace('ignored.txt', config);
      expect(result).toEqual([]);
      expect(mockFileService.filterFiles).toHaveBeenCalledWith(
        ['ignored.txt'],
        {
          respectGitIgnore: true,
          respectGeminiIgnore: true,
        },
      );
    });

    it('should not read ignored files when expanding a directory', async () => {
      mock({
        [CWD]: {
          'my-dir': {
            'not-ignored.txt': 'visible',
            'ignored.log': 'invisible',
          },
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files: string[]) =>
          files.filter((f) => !f.endsWith('ignored.log')),
        ),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      const result = await readPathFromWorkspace('my-dir', config);
      const resultText = result
        .map((p) => {
          if (typeof p === 'string') return p;
          if (typeof p === 'object' && p && 'text' in p) return p.text;
          return '';
        })
        .join('');

      expect(resultText).toContain('visible');
      expect(resultText).not.toContain('invisible');
      expect(mockFileService.filterFiles).toHaveBeenCalled();
    });

    it('should pass correct ignore flags to file service for a single file', async () => {
      mock({
        [CWD]: {
          'file.txt': 'content',
        },
      });
      const mockFileService = {
        filterFiles: vi.fn(() => []),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService, {
        respectGitIgnore: false,
        respectGeminiIgnore: true,
      });
      await readPathFromWorkspace('file.txt', config);
      expect(mockFileService.filterFiles).toHaveBeenCalledWith(['file.txt'], {
        respectGitIgnore: false,
        respectGeminiIgnore: true,
      });
    });

    it('should pass correct ignore flags to file service for a directory', async () => {
      mock({
        [CWD]: {
          'my-dir': {
            'file.txt': 'content',
          },
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files) => files),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService, {
        respectGitIgnore: true,
        respectGeminiIgnore: false,
      });
      await readPathFromWorkspace('my-dir', config);
      expect(mockFileService.filterFiles).toHaveBeenCalledWith(
        [path.join('my-dir', 'file.txt')],
        {
          respectGitIgnore: true,
          respectGeminiIgnore: false,
        },
      );
    });
  });

  it('should throw an error for an absolute path outside the workspace', async () => {
    const absPath = path.join(OUTSIDE_DIR, 'secret.txt');
    mock({
      [CWD]: {},
      [OUTSIDE_DIR]: {
        'secret.txt': 'secrets',
      },
    });
    // OUTSIDE_DIR is not added to the config's workspace
    const config = createMockConfig(CWD);
    await expect(readPathFromWorkspace(absPath, config)).rejects.toThrow(
      `Absolute path is outside of the allowed workspace: ${absPath}`,
    );
  });

  it('should throw an error if a relative path is not found anywhere', async () => {
    mock({
      [CWD]: {},
      [OTHER_DIR]: {},
    });
    const config = createMockConfig(CWD, [OTHER_DIR]);
    await expect(
      readPathFromWorkspace('not-found.txt', config),
    ).rejects.toThrow('Path not found in workspace: not-found.txt');
  });

  it('should prevent path traversal outside the workspace via relative paths', async () => {
    mock({
      [CWD]: {},
      [OUTSIDE_DIR]: {
        'secret.txt': 'secrets',
      },
    });
    const config = createMockConfig(CWD);
    // Attempt to traverse out of CWD to OUTSIDE_DIR
    const relativeTraversal = path.join('..', 'outside', 'secret.txt');
    await expect(
      readPathFromWorkspace(relativeTraversal, config),
    ).rejects.toThrow(`Path not found in workspace: ${relativeTraversal}`);
  });

  it('should prevent symlink escape outside the workspace', async () => {
    mock({
      [CWD]: {
        'malicious-link': mock.symlink({
          path: path.join(OUTSIDE_DIR, 'secret.txt'),
        }),
      },
      [OUTSIDE_DIR]: {
        'secret.txt': 'secrets',
      },
    });
    const config = createMockConfig(CWD);
    // Even if the link is in the workspace, its target is not.
    await expect(
      readPathFromWorkspace('malicious-link', config),
    ).rejects.toThrow('Path not found in workspace: malicious-link');
  });

  it('should block symlink escape inside a directory expansion (defense-in-depth)', async () => {
    mock({
      [CWD]: {
        'allowed-dir': {
          'legit.txt': 'legit content',
          'malicious-link.txt': mock.symlink({
            path: path.join(OUTSIDE_DIR, 'secret.txt'),
          }),
        },
      },
      [OUTSIDE_DIR]: {
        'secret.txt': 'secrets',
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [], mockFileService);
    const result = await readPathFromWorkspace('allowed-dir', config);
    const resultText = result
      .map((p) => {
        if (typeof p === 'string') return p;
        if (typeof p === 'object' && p && 'text' in p) return p.text;
        return '';
      })
      .join('');

    // Legit content should be there
    expect(resultText).toContain('legit content');
    // Secret content should NOT be there, but a skip message SHOULD be
    expect(resultText).not.toContain('secrets');
    expect(resultText).toContain(
      '--- Skipped malicious-link.txt: traverses outside workspace ---',
    );
  });

  it('should push multiple skip messages if multiple traversals are found in a directory', async () => {
    mock({
      [CWD]: {
        'bad-dir': {
          'link1.txt': mock.symlink({ path: path.join(OUTSIDE_DIR, 's1.txt') }),
          'link2.txt': mock.symlink({ path: path.join(OUTSIDE_DIR, 's2.txt') }),
          'good.txt': 'good content',
        },
      },
      [OUTSIDE_DIR]: {
        's1.txt': 'secret1',
        's2.txt': 'secret2',
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [], mockFileService);
    const result = await readPathFromWorkspace('bad-dir', config);
    const resultText = result
      .map((p) => {
        if (typeof p === 'string') return p;
        if (typeof p === 'object' && p && 'text' in p) return p.text;
        return '';
      })
      .join('');

    expect(resultText).toContain('good content');
    expect(resultText).toContain(
      '--- Skipped link1.txt: traverses outside workspace ---',
    );
    expect(resultText).toContain(
      '--- Skipped link2.txt: traverses outside workspace ---',
    );
    expect(resultText).not.toContain('secret1');
    expect(resultText).not.toContain('secret2');
  });

  // mock-fs permission simulation is unreliable on Windows.
  it.skipIf(process.platform === 'win32')(
    'should return an error string if reading a file with no permissions',
    async () => {
      mock({
        [CWD]: {
          'unreadable.txt': mock.file({
            content: 'you cannot read me',
            mode: 0o222, // Write-only
          }),
        },
      });
      const mockFileService = {
        filterFiles: vi.fn((files) => files),
      } as unknown as FileDiscoveryService;
      const config = createMockConfig(CWD, [], mockFileService);
      // processSingleFileContent catches the error and returns an error string.
      const result = await readPathFromWorkspace('unreadable.txt', config);
      const textResult = result[0] as string;

      // processSingleFileContent formats errors using the relative path from the target dir (CWD).
      expect(textResult).toContain('Error reading file unreadable.txt');
      expect(textResult).toMatch(/(EACCES|permission denied)/i);
    },
  );

  it('should return an error string for files exceeding the size limit', async () => {
    // Mock a file slightly larger than the 20MB limit defined in fileUtils.ts
    const largeContent = 'a'.repeat(21 * 1024 * 1024); // 21MB
    mock({
      [CWD]: {
        'large.txt': largeContent,
      },
    });
    const mockFileService = {
      filterFiles: vi.fn((files) => files),
    } as unknown as FileDiscoveryService;
    const config = createMockConfig(CWD, [], mockFileService);
    const result = await readPathFromWorkspace('large.txt', config);
    const textResult = result[0] as string;
    // The error message comes directly from processSingleFileContent
    expect(textResult).toBe('File size exceeds the 20MB limit.');
  });
});
