import { describe, it, expect, beforeEach, vi } from 'vitest';

const execFileMock = vi.hoisted(() => vi.fn());
vi.mock('child_process', () => ({ execFile: execFileMock }));

const {
  assertPathInAllowedRoots,
  dockerPrune,
  dockerRestart,
  execAllowed,
  InvalidShellArgError,
  runBashScript,
  runNpmScript,
} = await import('./shell.js');

function setExec(handler: (cmd: string, args: string[]) => { error?: Error; stdout?: string; stderr?: string }) {
  execFileMock.mockImplementation(
    (
      command: string,
      args: string[],
      _opts: unknown,
      cb: (err: unknown, result?: { stdout: string; stderr: string }) => void,
    ) => {
      const res = handler(command, args);
      if (res.error) cb(res.error);
      else cb(null, { stdout: res.stdout ?? '', stderr: res.stderr ?? '' });
    },
  );
}

describe('execAllowed', () => {
  beforeEach(() => vi.clearAllMocks());

  it('passes argv through to execFile without a shell', async () => {
    setExec(() => ({ stdout: 'ok' }));
    const result = await execAllowed('docker', ['ps', '-a']);
    expect(result.stdout).toBe('ok');
    expect(execFileMock).toHaveBeenCalledTimes(1);
    const [cmd, args] = execFileMock.mock.calls[0];
    expect(cmd).toBe('docker');
    expect(args).toEqual(['ps', '-a']);
  });
});

describe('dockerRestart', () => {
  beforeEach(() => vi.clearAllMocks());

  it('rejects names with shell metacharacters before reaching execFile', async () => {
    await expect(dockerRestart('foo; rm -rf /')).rejects.toBeInstanceOf(InvalidShellArgError);
    await expect(dockerRestart('foo bar')).rejects.toBeInstanceOf(InvalidShellArgError);
    await expect(dockerRestart('$(whoami)')).rejects.toBeInstanceOf(InvalidShellArgError);
    await expect(dockerRestart('')).rejects.toBeInstanceOf(InvalidShellArgError);
    expect(execFileMock).not.toHaveBeenCalled();
  });

  it('accepts valid container names and forwards them as a single argv element', async () => {
    setExec(() => ({ stdout: 'restarted' }));
    await dockerRestart('hermes-gateway');
    const [, args] = execFileMock.mock.calls[0];
    // `restart` and the name are separate argv slots — never one
    // concatenated string that could be re-parsed by a shell.
    expect(args).toEqual(['restart', 'hermes-gateway']);
  });

  it('non-string input throws InvalidShellArgError', async () => {
    // @ts-expect-error — testing runtime guard
    await expect(dockerRestart(undefined)).rejects.toBeInstanceOf(InvalidShellArgError);
    // @ts-expect-error — testing runtime guard
    await expect(dockerRestart(123)).rejects.toBeInstanceOf(InvalidShellArgError);
  });
});

describe('dockerPrune', () => {
  beforeEach(() => vi.clearAllMocks());

  it('rejects unknown prune kinds', async () => {
    // @ts-expect-error — exercising the runtime check
    await expect(dockerPrune('everything')).rejects.toBeInstanceOf(InvalidShellArgError);
  });

  it('emits the documented argv per kind', async () => {
    setExec(() => ({ stdout: '' }));
    await dockerPrune('container');
    expect(execFileMock.mock.calls.at(-1)![1]).toEqual(['container', 'prune', '-f']);
    await dockerPrune('image', { all: true });
    // `docker image prune -a -f` — kind first, then the verb, then -a/-f flags.
    expect(execFileMock.mock.calls.at(-1)![1]).toEqual(['image', 'prune', '-a', '-f']);
    await dockerPrune('volume');
    expect(execFileMock.mock.calls.at(-1)![1]).toEqual(['volume', 'prune', '-f']);
    await dockerPrune('builder');
    expect(execFileMock.mock.calls.at(-1)![1]).toEqual(['builder', 'prune', '-f']);
  });

  it('rejects --all on non-image kinds', async () => {
    await expect(dockerPrune('container', { all: true })).rejects.toBeInstanceOf(InvalidShellArgError);
  });
});

describe('assertPathInAllowedRoots', () => {
  it('accepts paths inside an allowed root', () => {
    expect(assertPathInAllowedRoots('/opt/projects/foo', ['/opt/projects'])).toBe('/opt/projects/foo');
    expect(assertPathInAllowedRoots('/opt/projects/foo/bar/baz', ['/opt/projects'])).toBe('/opt/projects/foo/bar/baz');
    expect(assertPathInAllowedRoots('/opt/projects', ['/opt/projects'])).toBe('/opt/projects');
  });

  it('rejects relative paths', () => {
    expect(() => assertPathInAllowedRoots('relative/path', ['/opt/projects'])).toThrow(InvalidShellArgError);
    expect(() => assertPathInAllowedRoots('./foo', ['/opt/projects'])).toThrow(InvalidShellArgError);
  });

  it('rejects ../ escape attempts even when prefix-matching the root', () => {
    expect(() => assertPathInAllowedRoots('/opt/projects/../etc', ['/opt/projects'])).toThrow(InvalidShellArgError);
    expect(() => assertPathInAllowedRoots('/opt/projects/../../etc', ['/opt/projects'])).toThrow(InvalidShellArgError);
  });

  it('rejects sibling directories that share a prefix string', () => {
    // /opt/projects-evil should NOT be accepted just because it starts with /opt/projects
    expect(() => assertPathInAllowedRoots('/opt/projects-evil/foo', ['/opt/projects'])).toThrow(InvalidShellArgError);
  });

  it('checks every allowed root', () => {
    expect(assertPathInAllowedRoots('/srv/app', ['/opt/projects', '/srv/app'])).toBe('/srv/app');
  });
});

describe('runBashScript', () => {
  beforeEach(() => vi.clearAllMocks());

  it('rejects scripts outside allowed roots', async () => {
    await expect(runBashScript('/etc/init.d/anything', [], { allowedRoots: ['/opt/projects'] }))
      .rejects.toBeInstanceOf(InvalidShellArgError);
  });

  it('runs a script that is inside an allowed root', async () => {
    setExec(() => ({ stdout: 'ok' }));
    const result = await runBashScript('/opt/projects/deploy.sh', ['--prod'], { allowedRoots: ['/opt/projects'] });
    expect(result.stdout).toBe('ok');
    const [cmd, args] = execFileMock.mock.calls[0];
    expect(cmd).toBe('bash');
    expect(args).toEqual(['/opt/projects/deploy.sh', '--prod']);
  });
});

describe('runNpmScript', () => {
  beforeEach(() => vi.clearAllMocks());

  it('rejects npm scripts not in the lifecycle allow-list', async () => {
    // @ts-expect-error — exercising the runtime guard
    await expect(runNpmScript('publish', { allowedRoots: ['/opt/projects'], cwd: '/opt/projects/foo' }))
      .rejects.toBeInstanceOf(InvalidShellArgError);
  });

  it('rejects cwd outside allowed roots', async () => {
    await expect(runNpmScript('typecheck', { allowedRoots: ['/opt/projects'], cwd: '/etc' }))
      .rejects.toBeInstanceOf(InvalidShellArgError);
  });

  it('runs a whitelisted lifecycle script in an allowed cwd', async () => {
    setExec(() => ({ stdout: 'ok' }));
    await runNpmScript('typecheck', { allowedRoots: ['/opt/projects'], cwd: '/opt/projects/foo' });
    const [cmd, args] = execFileMock.mock.calls[0];
    expect(cmd).toBe('npm');
    expect(args).toEqual(['run', 'typecheck']);
  });
});
