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

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

vi.unmock('./projectRegistry.js');

import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ProjectRegistry } from './projectRegistry.js';
import { lock } from 'proper-lockfile';

vi.mock('proper-lockfile');

describe('ProjectRegistry', () => {
  let tempDir: string;
  let registryPath: string;
  let baseDir1: string;
  let baseDir2: string;

  function normalizePath(p: string): string {
    let resolved = path.resolve(p);
    if (os.platform() === 'win32') {
      resolved = resolved.toLowerCase();
    }
    return resolved;
  }

  beforeEach(() => {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-registry-test-'));
    registryPath = path.join(tempDir, 'projects.json');
    baseDir1 = path.join(tempDir, 'base1');
    baseDir2 = path.join(tempDir, 'base2');
    fs.mkdirSync(baseDir1);
    fs.mkdirSync(baseDir2);

    vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined));
  });

  afterEach(() => {
    fs.rmSync(tempDir, { recursive: true, force: true });
    vi.clearAllMocks();
  });

  it('generates a short ID from the basename', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();
    const projectPath = path.join(tempDir, 'my-project');
    const shortId = await registry.getShortId(projectPath);
    expect(shortId).toBe('my-project');
  });

  it('slugifies the project name', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();
    const projectPath = path.join(tempDir, 'My Project! @2025');
    const shortId = await registry.getShortId(projectPath);
    expect(shortId).toBe('my-project-2025');
  });

  it('handles collisions with unique suffixes', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    const id1 = await registry.getShortId(path.join(tempDir, 'one', 'gemini'));
    const id2 = await registry.getShortId(path.join(tempDir, 'two', 'gemini'));
    const id3 = await registry.getShortId(
      path.join(tempDir, 'three', 'gemini'),
    );

    expect(id1).toBe('gemini');
    expect(id2).toBe('gemini-1');
    expect(id3).toBe('gemini-2');
  });

  it('persists and reloads the registry', async () => {
    const projectPath = path.join(tempDir, 'project-a');
    const registry1 = new ProjectRegistry(registryPath);
    await registry1.initialize();
    await registry1.getShortId(projectPath);

    const registry2 = new ProjectRegistry(registryPath);
    await registry2.initialize();
    const id = await registry2.getShortId(projectPath);

    expect(id).toBe('project-a');

    const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
    // Use the actual normalized path as key
    const normalizedPath = normalizePath(projectPath);
    expect(data.projects[normalizedPath]).toBe('project-a');
  });

  it('normalizes paths', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();
    const path1 = path.join(tempDir, 'project');
    const path2 = path.join(path1, '..', 'project');

    const id1 = await registry.getShortId(path1);
    const id2 = await registry.getShortId(path2);

    expect(id1).toBe(id2);
  });

  it('creates ownership markers in base directories', async () => {
    const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]);
    await registry.initialize();
    const projectPath = normalizePath(path.join(tempDir, 'project-x'));
    const shortId = await registry.getShortId(projectPath);

    expect(shortId).toBe('project-x');

    const marker1 = path.join(baseDir1, shortId, '.project_root');
    const marker2 = path.join(baseDir2, shortId, '.project_root');

    expect(normalizePath(fs.readFileSync(marker1, 'utf8'))).toBe(projectPath);
    expect(normalizePath(fs.readFileSync(marker2, 'utf8'))).toBe(projectPath);
  });

  it('recovers mapping from disk if registry is missing it', async () => {
    // 1. Setup a project with ownership markers
    const projectPath = normalizePath(path.join(tempDir, 'project-x'));
    const slug = 'project-x';
    const slugDir = path.join(baseDir1, slug);
    fs.mkdirSync(slugDir, { recursive: true });
    fs.writeFileSync(path.join(slugDir, '.project_root'), projectPath);

    // 2. Initialize registry (it has no projects.json)
    const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]);
    await registry.initialize();

    // 3. getShortId should find it from disk
    const shortId = await registry.getShortId(projectPath);
    expect(shortId).toBe(slug);

    // 4. It should have populated the markers in other base dirs too
    const marker2 = path.join(baseDir2, slug, '.project_root');
    expect(normalizePath(fs.readFileSync(marker2, 'utf8'))).toBe(projectPath);
  });

  it('handles collisions if a slug is taken on disk by another project', async () => {
    // 1. project-y takes 'gemini' on disk
    const projectY = normalizePath(path.join(tempDir, 'project-y'));
    const slug = 'gemini';
    const slugDir = path.join(baseDir1, slug);
    fs.mkdirSync(slugDir, { recursive: true });
    fs.writeFileSync(path.join(slugDir, '.project_root'), projectY);

    // 2. project-z tries to get shortId for 'gemini'
    const registry = new ProjectRegistry(registryPath, [baseDir1]);
    await registry.initialize();
    const projectZ = normalizePath(path.join(tempDir, 'gemini'));
    const shortId = await registry.getShortId(projectZ);

    // 3. It should avoid 'gemini' and pick 'gemini-1' (or similar)
    expect(shortId).not.toBe('gemini');
    expect(shortId).toBe('gemini-1');
  });

  it('invalidates registry mapping if disk ownership changed', async () => {
    // 1. Registry thinks my-project owns 'my-project'
    const projectPath = normalizePath(path.join(tempDir, 'my-project'));
    fs.writeFileSync(
      registryPath,
      JSON.stringify({
        projects: {
          [projectPath]: 'my-project',
        },
      }),
    );

    // 2. But disk says project-b owns 'my-project'
    const slugDir = path.join(baseDir1, 'my-project');
    fs.mkdirSync(slugDir, { recursive: true });
    fs.writeFileSync(
      path.join(slugDir, '.project_root'),
      normalizePath(path.join(tempDir, 'project-b')),
    );

    // 3. my-project asks for its ID
    const registry = new ProjectRegistry(registryPath, [baseDir1]);
    await registry.initialize();
    const id = await registry.getShortId(projectPath);

    // 4. It should NOT get 'my-project' because it's owned by project-b on disk.
    // It should get 'my-project-1' instead.
    expect(id).not.toBe('my-project');
    expect(id).toBe('my-project-1');
  });

  it('repairs missing ownership markers in other base directories', async () => {
    const projectPath = normalizePath(path.join(tempDir, 'project-repair'));
    const slug = 'repair-me';

    // 1. Marker exists in base1 but NOT in base2
    const slugDir1 = path.join(baseDir1, slug);
    fs.mkdirSync(slugDir1, { recursive: true });
    fs.writeFileSync(path.join(slugDir1, '.project_root'), projectPath);

    const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]);
    await registry.initialize();

    // 2. getShortId should find it and repair base2
    const shortId = await registry.getShortId(projectPath);
    expect(shortId).toBe(slug);

    const marker2 = path.join(baseDir2, slug, '.project_root');
    expect(fs.existsSync(marker2)).toBe(true);
    expect(normalizePath(fs.readFileSync(marker2, 'utf8'))).toBe(projectPath);
  });

  it('heals if both markers are missing but registry mapping exists', async () => {
    const projectPath = normalizePath(path.join(tempDir, 'project-heal-both'));
    const slug = 'heal-both';

    // 1. Registry has the mapping
    fs.writeFileSync(
      registryPath,
      JSON.stringify({
        projects: {
          [projectPath]: slug,
        },
      }),
    );

    // 2. No markers on disk
    const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]);
    await registry.initialize();

    // 3. getShortId should recreate them
    const id = await registry.getShortId(projectPath);
    expect(id).toBe(slug);

    expect(fs.existsSync(path.join(baseDir1, slug, '.project_root'))).toBe(
      true,
    );
    expect(fs.existsSync(path.join(baseDir2, slug, '.project_root'))).toBe(
      true,
    );
    expect(
      normalizePath(
        fs.readFileSync(path.join(baseDir1, slug, '.project_root'), 'utf8'),
      ),
    ).toBe(projectPath);
  });

  it('handles corrupted (unreadable) ownership markers by picking a new slug', async () => {
    const projectPath = normalizePath(path.join(tempDir, 'corrupt-slug'));
    const slug = 'corrupt-slug';

    // 1. Marker exists but is owned by someone else
    const slugDir = path.join(baseDir1, slug);
    fs.mkdirSync(slugDir, { recursive: true });
    fs.writeFileSync(
      path.join(slugDir, '.project_root'),
      normalizePath(path.join(tempDir, 'something-else')),
    );

    // 2. Registry also thinks we own it
    fs.writeFileSync(
      registryPath,
      JSON.stringify({
        projects: {
          [projectPath]: slug,
        },
      }),
    );

    const registry = new ProjectRegistry(registryPath, [baseDir1]);
    await registry.initialize();

    // 3. It should see the collision/corruption and pick a new one
    const id = await registry.getShortId(projectPath);
    expect(id).toBe(`${slug}-1`);
  });

  it('throws on lock timeout', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    vi.mocked(lock).mockRejectedValue(new Error('Lock timeout'));

    await expect(registry.getShortId('/foo')).rejects.toThrow('Lock timeout');
    expect(lock).toHaveBeenCalledWith(
      registryPath,
      expect.objectContaining({
        retries: expect.any(Object),
      }),
    );
  });

  it('throws if not initialized', async () => {
    const registry = new ProjectRegistry(registryPath);
    await expect(registry.getShortId('/foo')).rejects.toThrow(
      'ProjectRegistry must be initialized before use',
    );
  });

  it('retries on EBUSY during save', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    const originalRename = fs.promises.rename;
    const renameSpy = vi.spyOn(fs.promises, 'rename');
    let ebusyCount = 0;

    renameSpy.mockImplementation(async (oldPath, newPath) => {
      // Only throw for the specific temporary file generated by save()
      if (oldPath.toString().includes('.tmp') && ebusyCount < 2) {
        ebusyCount++;
        const err = Object.assign(new Error('Resource busy or locked'), {
          code: 'EBUSY',
        });
        throw err;
      }
      // On success, call the original native rename implementation
      return originalRename(oldPath, newPath);
    });

    const projectPath = path.join(tempDir, 'ebusy-project');
    const shortId = await registry.getShortId(projectPath);
    expect(shortId).toBe('ebusy-project');
    expect(ebusyCount).toBe(2);

    // Verify it actually saved properly after retries
    const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
    expect(data.projects[normalizePath(projectPath)]).toBe('ebusy-project');

    renameSpy.mockRestore();
  });

  it('re-throws error if save ultimately fails after retries', async () => {
    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    const renameSpy = vi.spyOn(fs.promises, 'rename');
    const expectedError = Object.assign(new Error('Persistent EBUSY'), {
      code: 'EBUSY',
    });

    // Mock rename to ALWAYS fail
    renameSpy.mockRejectedValue(expectedError);

    const projectPath = path.join(tempDir, 'failing-project');
    await expect(registry.getShortId(projectPath)).rejects.toThrow(
      'Persistent EBUSY',
    );

    renameSpy.mockRestore();
  });

  it('protects against data destruction by throwing on EACCES instead of resetting', async () => {
    // 1. Write valid registry data
    fs.writeFileSync(
      registryPath,
      JSON.stringify({ projects: { '/foo': 'bar' } }),
    );

    const registry = new ProjectRegistry(registryPath);

    // 2. Mock readFile to throw a permissions error
    const readFileSpy = vi.spyOn(fs.promises, 'readFile');
    readFileSpy.mockRejectedValue(
      Object.assign(new Error('Permission denied'), { code: 'EACCES' }),
    );

    // 3. Initialization should NOT swallow the error
    await expect(registry.initialize()).rejects.toThrow('Permission denied');

    readFileSpy.mockRestore();
  });

  it('recovers gracefully if registry is an empty object (invalid schema)', async () => {
    // 1. Write an empty object which is valid JSON but invalid schema
    fs.writeFileSync(registryPath, '{}');

    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    // 2. It should not crash and should allow adding new projects
    const projectPath = path.join(tempDir, 'my-project');
    const shortId = await registry.getShortId(projectPath);

    expect(shortId).toBe('my-project');

    // 3. Verify it healed the file
    const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
    expect(data.projects).toBeDefined();
    expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
  });

  it('recovers gracefully if registry projects property is an array (invalid schema)', async () => {
    // 1. Write an object where 'projects' is an array
    fs.writeFileSync(registryPath, JSON.stringify({ projects: [] }));

    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    // 2. It should reset and allow adding new projects correctly
    const projectPath = path.join(tempDir, 'my-project');
    const shortId = await registry.getShortId(projectPath);

    expect(shortId).toBe('my-project');

    // 3. Verify it healed the file to an object
    const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
    expect(data.projects).toBeDefined();
    expect(Array.isArray(data.projects)).toBe(false);
    expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
  });

  it('recovers gracefully if registry contains malicious slugs (path traversal)', async () => {
    // 1. Write a registry with a path traversal slug
    fs.writeFileSync(
      registryPath,
      JSON.stringify({ projects: { '/some/path': '../../etc/passwd' } }),
    );

    const registry = new ProjectRegistry(registryPath);
    await registry.initialize();

    // 2. It should identify as invalid and reset
    const projectPath = path.join(tempDir, 'my-project');
    const shortId = await registry.getShortId(projectPath);

    expect(shortId).toBe('my-project');

    // 3. Verify it healed the file and didn't preserve the malicious entry
    const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
    expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
    expect(Object.values(data.projects)).not.toContain('../../etc/passwd');
  });
});
