import { BadRequestException } from '@nestjs/common';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WorkspaceService } from './workspace.service.js';

type ExecFileMock = (
  command: string,
  args: readonly string[],
  options: { cwd: string },
  callback: (error: Error | null, stdout: string, stderr: string) => void,
) => void;

const { execFileMock } = vi.hoisted(() => ({
  execFileMock: vi.fn<ExecFileMock>(),
}));

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

describe('WorkspaceService', () => {
  let service: WorkspaceService;

  beforeEach(() => {
    execFileMock.mockReset();
    execFileMock.mockImplementation((_command, _args, _options, callback) => {
      callback(null, '', '');
    });
    service = new WorkspaceService();
  });

  describe('resolvePath', () => {
    it('resolves user workspace path', () => {
      const result = service.resolvePath({
        id: 'proj1',
        ownerType: 'user',
        userId: 'user1',
        teamId: null,
      });
      expect(result).toContain(path.join('users', 'user1', 'proj1'));
    });

    it('resolves team workspace path', () => {
      const result = service.resolvePath({
        id: 'proj1',
        ownerType: 'team',
        userId: 'user1',
        teamId: 'team1',
      });
      expect(result).toContain(path.join('teams', 'team1', 'proj1'));
    });

    it('falls back to user path when ownerType is team but teamId is null', () => {
      const result = service.resolvePath({
        id: 'proj1',
        ownerType: 'team',
        userId: 'user1',
        teamId: null,
      });
      expect(result).toContain(path.join('users', 'user1', 'proj1'));
    });

    it('uses MOSAIC_ROOT env var as the base path', () => {
      const originalRoot = process.env['MOSAIC_ROOT'];
      process.env['MOSAIC_ROOT'] = '/custom/root';
      const customService = new WorkspaceService();
      const result = customService.resolvePath({
        id: 'proj1',
        ownerType: 'user',
        userId: 'user1',
        teamId: null,
      });
      expect(result).toMatch(/^\/custom\/root/);
      // Restore
      if (originalRoot === undefined) {
        delete process.env['MOSAIC_ROOT'];
      } else {
        process.env['MOSAIC_ROOT'] = originalRoot;
      }
    });

    it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => {
      const originalRoot = process.env['MOSAIC_ROOT'];
      delete process.env['MOSAIC_ROOT'];
      const defaultService = new WorkspaceService();
      const result = defaultService.resolvePath({
        id: 'proj2',
        ownerType: 'user',
        userId: 'user2',
        teamId: null,
      });
      expect(result).toMatch(/^\/opt\/mosaic/);
      // Restore
      if (originalRoot !== undefined) {
        process.env['MOSAIC_ROOT'] = originalRoot;
      }
    });
  });

  describe('create', () => {
    const project = {
      id: 'project-1',
      ownerType: 'user',
      userId: 'user-1',
      teamId: null,
    } as const;

    let originalRoot: string | undefined;
    let temporaryRoot: string;

    beforeEach(async () => {
      originalRoot = process.env['MOSAIC_ROOT'];
      temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
      process.env['MOSAIC_ROOT'] = temporaryRoot;
      service = new WorkspaceService();
    });

    afterEach(async () => {
      if (originalRoot === undefined) {
        delete process.env['MOSAIC_ROOT'];
      } else {
        process.env['MOSAIC_ROOT'] = originalRoot;
      }
      await fs.rm(temporaryRoot, { recursive: true, force: true });
    });

    it.each([
      ['a leading-dash URL', '--upload-pack=sh -c id'],
      ['an ext remote helper', 'ext::sh -c id'],
      ['a file URL', 'file:///tmp/repository'],
      ['an unparseable value', 'not a url'],
      ['an SSH shorthand', 'git@example.com:acme/repository.git'],
      ['a scheme without //', 'https:example.com/acme/repository.git'],
      ['a hostless git URL', 'git:///tmp/repository'],
    ])('rejects %s before invoking git', async (_description, repoUrl) => {
      await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
      expect(execFileMock).not.toHaveBeenCalled();
    });

    it.each([
      ['an HTTPS URL', 'https://example.com/acme/repository.git'],
      ['a git protocol URL', 'git://example.com/acme/repository.git'],
    ])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
      const workspacePath = await service.create(project, repoUrl);

      expect(execFileMock).toHaveBeenCalledOnce();
      expect(execFileMock).toHaveBeenCalledWith(
        'git',
        [
          '-c',
          'protocol.ext.allow=never',
          '-c',
          'protocol.file.allow=never',
          'clone',
          '--',
          repoUrl,
          '.',
        ],
        { cwd: workspacePath },
        expect.any(Function),
      );
    });
  });
});
