import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'node:path';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { validateRemotePath, validateLocalPath } from './path-policy.js';

describe('ssh/path-policy validateRemotePath', () => {
  it('accepts a path exactly equal to the prefix', () => {
    const r = validateRemotePath('/home/u', '/home/u');
    expect(r).toEqual({ ok: true, normalized: '/home/u' });
  });

  it('accepts a path under the prefix', () => {
    const r = validateRemotePath('/home/u/file', '/home/u');
    expect(r).toEqual({ ok: true, normalized: '/home/u/file' });
  });

  it('accepts a deep path', () => {
    const r = validateRemotePath('/home/u/sub/dir/file.txt', '/home/u');
    expect(r.ok).toBe(true);
  });

  it('normalizes redundant slashes and ./', () => {
    const r = validateRemotePath('/home/u/./sub//file', '/home/u');
    expect(r).toEqual({ ok: true, normalized: '/home/u/sub/file' });
  });

  it('trims trailing slash (except for root)', () => {
    const r = validateRemotePath('/home/u/sub/', '/home/u');
    expect(r).toEqual({ ok: true, normalized: '/home/u/sub' });
  });

  it('rejects empty', () => {
    expect(validateRemotePath('', '/home/u').reason).toBe('empty');
  });

  it('rejects null byte', () => {
    expect(validateRemotePath('/home/u/\x00bad', '/home/u').reason).toBe('has_nul');
  });

  it('rejects relative candidate against absolute prefix as outside_prefix', () => {
    // Relative paths no longer trigger `not_absolute` — the prefix style
    // determines the comparison, so a relative path against an absolute
    // prefix simply falls out of the prefix.
    expect(validateRemotePath('relative/path', '/home/u').reason).toBe('outside_prefix');
    expect(validateRemotePath('./path', '/home/u').reason).toBe('outside_prefix');
  });

  it('rejects parent-ref anywhere in path', () => {
    expect(validateRemotePath('/home/u/../etc/passwd', '/home/u').reason).toBe('has_parent_ref');
    expect(validateRemotePath('/..', '/home/u').reason).toBe('has_parent_ref');
    expect(validateRemotePath('/home/u/sub/../sub2', '/home/u').reason).toBe('has_parent_ref');
  });

  it('rejects sibling that shares a prefix string (segment boundary)', () => {
    // '/home/u2/...' is NOT inside '/home/u'.
    expect(validateRemotePath('/home/u2/file', '/home/u').reason).toBe('outside_prefix');
    expect(validateRemotePath('/home/user/file', '/home/u').reason).toBe('outside_prefix');
  });

  it('rejects path entirely outside prefix', () => {
    expect(validateRemotePath('/etc/passwd', '/home/u').reason).toBe('outside_prefix');
  });

  it('handles root prefix "/"', () => {
    expect(validateRemotePath('/anywhere', '/').ok).toBe(true);
    expect(validateRemotePath('/', '/').ok).toBe(true);
  });

  it('accepts prefix with trailing slash', () => {
    expect(validateRemotePath('/home/u/file', '/home/u/').ok).toBe(true);
    expect(validateRemotePath('/home/u', '/home/u/').ok).toBe(true);
  });

  it('accepts POSIX prefix without leading slash', () => {
    expect(validateRemotePath('srv/agent', 'srv/agent').ok).toBe(true);
    expect(validateRemotePath('srv/agent/file', 'srv/agent').ok).toBe(true);
    expect(validateRemotePath('srv/agent/sub/file.txt', 'srv/agent').ok).toBe(true);
  });

  it('rejects out-of-prefix path for no-leading-slash prefix', () => {
    expect(validateRemotePath('other/path', 'srv/agent').reason).toBe('outside_prefix');
    // Segment boundary: 'srv/agent2' is not under 'srv/agent'.
    expect(validateRemotePath('srv/agent2/file', 'srv/agent').reason).toBe('outside_prefix');
  });

  it('accepts Windows drive-letter prefix', () => {
    expect(validateRemotePath('C:\\Users\\agent', 'C:\\Users\\agent').ok).toBe(true);
    expect(validateRemotePath('C:\\Users\\agent\\file.txt', 'C:\\Users\\agent').ok).toBe(true);
    expect(validateRemotePath('C:\\Users\\agent\\sub\\dir\\f', 'C:\\Users\\agent').ok).toBe(true);
  });

  it('rejects sibling drive path (segment boundary)', () => {
    expect(validateRemotePath('C:\\Users\\agent2\\f', 'C:\\Users\\agent').reason).toBe('outside_prefix');
    expect(validateRemotePath('D:\\Users\\agent\\f', 'C:\\Users\\agent').reason).toBe('outside_prefix');
  });

  it('rejects Windows-style parent-ref', () => {
    expect(validateRemotePath('C:\\Users\\agent\\..\\admin', 'C:\\Users\\agent').reason).toBe('has_parent_ref');
    expect(validateRemotePath('C:\\..\\Windows', 'C:\\Users\\agent').reason).toBe('has_parent_ref');
  });

  it('accepts UNC (\\\\server\\share) prefix', () => {
    expect(validateRemotePath('\\\\srv\\share\\agent', '\\\\srv\\share\\agent').ok).toBe(true);
    expect(validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent').ok).toBe(true);
  });

  it('collapses repeated backslashes in Windows path', () => {
    const r = validateRemotePath('C:\\Users\\\\agent\\\\file', 'C:\\Users\\agent');
    expect(r.ok).toBe(true);
    expect(r.normalized).toBe('C:\\Users\\agent\\file');
  });

  it('rejects empty prefix candidate via outside_prefix when prefix is non-trivial', () => {
    // Sanity: validateRemotePath is called with a non-empty candidate, but the
    // prefix may be `/` (root) — that case already covered by 'handles root prefix'.
    // Here just ensure the new POSIX-without-leading-slash mode doesn't accept
    // arbitrary paths that don't share the prefix root.
    expect(validateRemotePath('completely/other', 'home/user').reason).toBe('outside_prefix');
  });
});

describe('ssh/path-policy validateLocalPath', () => {
  let root: string;
  let workspace: string;

  beforeEach(async () => {
    root = await fs.mkdtemp(path.join(tmpdir(), 'ssh-path-'));
    // Use realpath so the workspace root has no symlinks in its ancestry
    // (e.g. /tmp → /private/tmp on macOS, /var → /private/var, etc.).
    root = await fs.realpath(root);
    workspace = path.join(root, 'workspace');
    await fs.mkdir(workspace, { recursive: true });
    await fs.mkdir(path.join(workspace, 'sub'), { recursive: true });
    await fs.writeFile(path.join(workspace, 'file.txt'), 'hello');
  });

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

  it('accepts an existing file in workspace', async () => {
    const r = await validateLocalPath({
      localPath: path.join(workspace, 'file.txt'),
      workspaceRoot: workspace,
    });
    expect(r.ok).toBe(true);
    expect(r.resolved).toBe(path.join(workspace, 'file.txt'));
  });

  it('accepts a path that does not exist yet (download target)', async () => {
    const r = await validateLocalPath({
      localPath: path.join(workspace, 'sub', 'new-file.txt'),
      workspaceRoot: workspace,
      mustExist: false,
    });
    expect(r.ok).toBe(true);
  });

  it('rejects mustExist=true for missing file', async () => {
    const r = await validateLocalPath({
      localPath: path.join(workspace, 'missing.txt'),
      workspaceRoot: workspace,
      mustExist: true,
    });
    expect(r.reason).toBe('not_found');
  });

  it('rejects empty', async () => {
    const r = await validateLocalPath({ localPath: '', workspaceRoot: workspace });
    expect(r.reason).toBe('empty');
  });

  it('rejects null byte', async () => {
    const r = await validateLocalPath({
      localPath: path.join(workspace, 'bad\x00name'),
      workspaceRoot: workspace,
    });
    expect(r.reason).toBe('has_nul');
  });

  it('rejects path outside workspace via absolute', async () => {
    const r = await validateLocalPath({
      localPath: '/etc/passwd',
      workspaceRoot: workspace,
    });
    expect(r.reason).toBe('outside_workspace');
  });

  it('rejects path outside workspace via relative ..', async () => {
    const r = await validateLocalPath({
      localPath: '../escape',
      workspaceRoot: workspace,
    });
    expect(r.reason).toBe('outside_workspace');
  });

  it('rejects when the leaf itself is a symlink', async () => {
    const link = path.join(workspace, 'evil-link');
    await fs.symlink('/etc/passwd', link);
    const r = await validateLocalPath({ localPath: link, workspaceRoot: workspace });
    expect(r.reason).toBe('leaf_is_symlink');
  });

  it('rejects when a parent dir is a symlink', async () => {
    // workspace/evil-dir → workspace/sub (symlink). path through it is rejected.
    await fs.symlink(path.join(workspace, 'sub'), path.join(workspace, 'evil-dir'));
    const r = await validateLocalPath({
      localPath: path.join(workspace, 'evil-dir', 'inside.txt'),
      workspaceRoot: workspace,
    });
    expect(r.reason).toBe('symlink_in_path');
  });

  it('rejects symlink even if it points back inside workspace', async () => {
    const link = path.join(workspace, 'self-link');
    await fs.symlink(path.join(workspace, 'file.txt'), link);
    const r = await validateLocalPath({ localPath: link, workspaceRoot: workspace });
    expect(r.reason).toBe('leaf_is_symlink');
  });

  it('resolves relative paths against workspace', async () => {
    const r = await validateLocalPath({
      localPath: 'sub/x.txt',
      workspaceRoot: workspace,
      mustExist: false,
    });
    expect(r.ok).toBe(true);
    expect(r.resolved).toBe(path.join(workspace, 'sub', 'x.txt'));
  });

  it('accepts the workspace root itself', async () => {
    const r = await validateLocalPath({ localPath: workspace, workspaceRoot: workspace });
    expect(r.ok).toBe(true);
  });

  it('normalizes ./ and double slashes', async () => {
    const messy = path.join(workspace, '.', 'sub', '..', 'file.txt');
    const r = await validateLocalPath({ localPath: messy, workspaceRoot: workspace });
    expect(r.ok).toBe(true);
  });
});
