import fs from 'fs/promises'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { resolveFilesystemBaseDir } from '../config'
import { handleDeleteTool } from '../tools/delete'
import { handleEditTool } from '../tools/edit'
import { handleGlobTool } from '../tools/glob'
import { handleLsTool } from '../tools/ls'
import { handleReadTool } from '../tools/read'
import { handleWriteTool } from '../tools/write'
import * as types from '../types'
import { validatePath } from '../types'

describe('filesystem MCP security', () => {
  const tempDirs: string[] = []

  async function createTempDir(prefix: string) {
    const tempRoot = path.join(process.cwd(), '.context', 'vitest-temp')
    await fs.mkdir(tempRoot, { recursive: true })
    const tempDir = await fs.mkdtemp(path.join(tempRoot, prefix))
    tempDirs.push(tempDir)
    return tempDir
  }

  afterEach(async () => {
    vi.restoreAllMocks()
    await Promise.all(tempDirs.splice(0).map((tempDir) => fs.rm(tempDir, { recursive: true, force: true })))
  })

  it('prefers WORKSPACE_ROOT and falls back to args for filesystem root', () => {
    expect(resolveFilesystemBaseDir(['C:/args-root'], {})).toBe('C:/args-root')
    expect(resolveFilesystemBaseDir(['C:/args-root'], { WORKSPACE_ROOT: 'C:/env-root' })).toBe('C:/env-root')
    expect(resolveFilesystemBaseDir([], {})).toBeUndefined()
  })

  it('allows paths inside the configured root and rejects paths outside it', async () => {
    const workspaceRoot = await createTempDir('filesystem-root-')
    const outsideRoot = await createTempDir('filesystem-outside-')
    const insideFile = path.join(workspaceRoot, 'inside.txt')
    const outsideFile = path.join(outsideRoot, 'outside.txt')

    await fs.writeFile(insideFile, 'inside')
    await fs.writeFile(outsideFile, 'outside')

    await expect(validatePath(insideFile, workspaceRoot)).resolves.toBe(insideFile)
    await expect(validatePath(outsideFile, workspaceRoot)).rejects.toThrow('outside the configured workspace root')
  })

  it('rejects symlink escapes outside the configured root', async () => {
    const workspaceRoot = await createTempDir('filesystem-symlink-root-')
    const outsideRoot = await createTempDir('filesystem-symlink-outside-')
    const outsideFile = path.join(outsideRoot, 'secret.txt')
    const symlinkPath = path.join(workspaceRoot, 'escape-link')

    await fs.writeFile(outsideFile, 'top-secret')
    await fs.symlink(outsideFile, symlinkPath)

    await expect(validatePath(symlinkPath, workspaceRoot)).rejects.toThrow('outside the configured workspace root')
  })

  it('rejects relative path traversal outside the configured root', async () => {
    const workspaceRoot = await createTempDir('filesystem-relative-root-')
    const outsideFile = path.join(path.dirname(workspaceRoot), 'outside.txt')

    await fs.writeFile(outsideFile, 'outside')

    await expect(validatePath('../outside.txt', workspaceRoot)).rejects.toThrow('outside the configured workspace root')
  })

  it('rejects home expansion outside the configured root', async () => {
    const workspaceRoot = await createTempDir('filesystem-home-root-')

    await expect(validatePath('~/sensitive-file', workspaceRoot)).rejects.toThrow(
      'outside the configured workspace root'
    )
  })

  it('falls back to process.cwd() when baseDir is omitted', async () => {
    const workspaceRoot = await createTempDir('filesystem-cwd-root-')
    const allowedFile = path.join(workspaceRoot, 'allowed.txt')
    const outsideFile = path.join(path.dirname(workspaceRoot), 'outside.txt')

    await fs.writeFile(allowedFile, 'allowed')
    await fs.writeFile(outsideFile, 'outside')

    vi.spyOn(process, 'cwd').mockReturnValue(workspaceRoot)

    await expect(validatePath('allowed.txt')).resolves.toBe(allowedFile)
    await expect(validatePath('../outside.txt')).rejects.toThrow('outside the configured workspace root')
  })

  it('glob excludes files reached via symlinked directories outside root', async () => {
    const workspaceRoot = await createTempDir('glob-symlink-root-')
    const outsideRoot = await createTempDir('glob-symlink-outside-')

    // Create a file inside the workspace and one outside
    const legitFile = path.join(workspaceRoot, 'legit.txt')
    const secretFile = path.join(outsideRoot, 'secret.txt')
    await fs.writeFile(legitFile, 'legit')
    await fs.writeFile(secretFile, 'secret')

    // Create a symlink inside workspace pointing to the outside directory
    await fs.symlink(outsideRoot, path.join(workspaceRoot, 'escape-dir'))

    // Mock ripgrep to return both files (simulating --follow traversing the symlink)
    vi.spyOn(types, 'runRipgrep').mockResolvedValue({
      ok: true,
      stdout: [legitFile, secretFile].join('\n'),
      exitCode: 0
    })

    const result = await handleGlobTool({ pattern: '*.txt' }, workspaceRoot)
    const text = result.content[0].text

    expect(text).toContain('legit.txt')
    expect(text).not.toContain('secret.txt')
  })

  it('ls excludes symlinked directories outside root in recursive mode', async () => {
    const workspaceRoot = await createTempDir('ls-symlink-root-')
    const outsideRoot = await createTempDir('ls-symlink-outside-')

    await fs.writeFile(path.join(workspaceRoot, 'legit.txt'), 'legit')
    await fs.mkdir(path.join(outsideRoot, 'private'))
    await fs.writeFile(path.join(outsideRoot, 'private', 'secret.txt'), 'secret')

    // Create a symlink inside workspace pointing to the outside directory
    await fs.symlink(outsideRoot, path.join(workspaceRoot, 'escape-dir'))

    const result = await handleLsTool({ recursive: true }, workspaceRoot)
    const text = result.content[0].text

    expect(text).toContain('legit.txt')
    // The symlink entry itself may appear, but its children should not be listed
    expect(text).not.toContain('secret.txt')
  })

  describe('write/edit/delete/read reject escapes before mutating the filesystem', () => {
    const ESCAPE_ERROR = 'outside the configured workspace root'

    it('write rejects ../escape and a symlink pointing outside the root', async () => {
      const workspaceRoot = await createTempDir('write-escape-root-')
      const outsideRoot = await createTempDir('write-escape-outside-')
      const outsideFile = path.join(outsideRoot, 'target.txt')
      await fs.writeFile(outsideFile, 'original')

      await expect(handleWriteTool({ file_path: '../escape.txt', content: 'pwned' }, workspaceRoot)).rejects.toThrow(
        ESCAPE_ERROR
      )
      // No file leaked into the parent of the workspace root.
      await expect(fs.stat(path.join(path.dirname(workspaceRoot), 'escape.txt'))).rejects.toMatchObject({
        code: 'ENOENT'
      })

      // Symlink inside the workspace pointing outside it must be rejected before writing.
      const symlinkPath = path.join(workspaceRoot, 'escape-link')
      await fs.symlink(outsideFile, symlinkPath)
      await expect(handleWriteTool({ file_path: 'escape-link', content: 'pwned' }, workspaceRoot)).rejects.toThrow(
        ESCAPE_ERROR
      )
      await expect(fs.readFile(outsideFile, 'utf-8')).resolves.toBe('original')
    })

    it('edit rejects ../escape and a symlink pointing outside the root', async () => {
      const workspaceRoot = await createTempDir('edit-escape-root-')
      const outsideRoot = await createTempDir('edit-escape-outside-')
      const outsideFile = path.join(outsideRoot, 'target.txt')
      await fs.writeFile(outsideFile, 'original')

      await expect(
        handleEditTool({ file_path: '../target.txt', old_string: 'original', new_string: 'pwned' }, workspaceRoot)
      ).rejects.toThrow(ESCAPE_ERROR)
      await expect(fs.readFile(outsideFile, 'utf-8')).resolves.toBe('original')

      const symlinkPath = path.join(workspaceRoot, 'escape-link')
      await fs.symlink(outsideFile, symlinkPath)
      await expect(
        handleEditTool({ file_path: 'escape-link', old_string: 'original', new_string: 'pwned' }, workspaceRoot)
      ).rejects.toThrow(ESCAPE_ERROR)
      await expect(fs.readFile(outsideFile, 'utf-8')).resolves.toBe('original')
    })

    it('delete rejects ../escape and a symlink pointing outside the root', async () => {
      const workspaceRoot = await createTempDir('delete-escape-root-')
      const outsideRoot = await createTempDir('delete-escape-outside-')
      const outsideFile = path.join(outsideRoot, 'target.txt')
      await fs.writeFile(outsideFile, 'keep-me')

      await expect(handleDeleteTool({ path: '../target.txt' }, workspaceRoot)).rejects.toThrow(ESCAPE_ERROR)
      await expect(fs.readFile(outsideFile, 'utf-8')).resolves.toBe('keep-me')

      const symlinkPath = path.join(workspaceRoot, 'escape-link')
      await fs.symlink(outsideFile, symlinkPath)
      await expect(handleDeleteTool({ path: 'escape-link' }, workspaceRoot)).rejects.toThrow(ESCAPE_ERROR)
      // Both the symlink and its target must survive.
      await expect(fs.readFile(outsideFile, 'utf-8')).resolves.toBe('keep-me')
    })

    it('read rejects ../escape and a symlink pointing outside the root', async () => {
      const workspaceRoot = await createTempDir('read-escape-root-')
      const outsideRoot = await createTempDir('read-escape-outside-')
      const outsideFile = path.join(outsideRoot, 'secret.txt')
      await fs.writeFile(outsideFile, 'top-secret')

      await expect(handleReadTool({ file_path: '../secret.txt' }, workspaceRoot)).rejects.toThrow(ESCAPE_ERROR)

      const symlinkPath = path.join(workspaceRoot, 'escape-link')
      await fs.symlink(outsideFile, symlinkPath)
      await expect(handleReadTool({ file_path: 'escape-link' }, workspaceRoot)).rejects.toThrow(ESCAPE_ERROR)
    })
  })
})
