import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpTokenService, PermissionCeilingError } from '../src/services/mcp-token.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpTokenRepository, McpTokenWithRelations } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import type { RbacService } from '../src/services/rbac.service.js';
import { hashToken, isMcpToken, TOKEN_PREFIX } from '@mcpctl/shared';

const PROJECT = { id: 'cproj1', name: 'myproj' };

function makeRow(overrides: Partial<McpTokenWithRelations> = {}): McpTokenWithRelations {
  return {
    id: 'ctok1',
    name: 'mytok',
    projectId: PROJECT.id,
    tokenHash: 'deadbeef',
    tokenPrefix: 'mcpctl_pat_abcd',
    ownerId: 'cuser1',
    description: '',
    createdAt: new Date(),
    expiresAt: null,
    lastUsedAt: null,
    revokedAt: null,
    project: PROJECT,
    owner: { id: 'cuser1', email: 'alice@example.com' },
    ...overrides,
  };
}

function mockTokenRepo(): IMcpTokenRepository {
  return {
    findAll: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByHash: vi.fn(async () => null),
    findByNameAndProject: vi.fn(async () => null),
    create: vi.fn(async (input) => makeRow({
      name: input.name,
      projectId: input.projectId,
      tokenHash: input.tokenHash,
      tokenPrefix: input.tokenPrefix,
      ownerId: input.ownerId,
      description: input.description ?? '',
      expiresAt: input.expiresAt ?? null,
    })),
    revoke: vi.fn(async (id) => makeRow({ id, revokedAt: new Date() })),
    touchLastUsed: vi.fn(async () => {}),
    delete: vi.fn(async () => {}),
  };
}

function mockProjectRepo(): IProjectRepository {
  return {
    findById: vi.fn(async (id) => (id === PROJECT.id ? PROJECT : null)),
    findByName: vi.fn(async (name) => (name === PROJECT.name ? PROJECT : null)),
    // minimal stubs for the rest — not exercised in these tests
    findAll: vi.fn(async () => []),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    attachServer: vi.fn(),
    detachServer: vi.fn(),
    listServers: vi.fn(async () => []),
  } as unknown as IProjectRepository;
}

function mockRbacRepo(): IRbacDefinitionRepository {
  return {
    findAll: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByName: vi.fn(async () => null),
    create: vi.fn(async () => ({ id: 'rbac-1', name: 'x', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date() })),
    update: vi.fn(),
    delete: vi.fn(async () => {}),
  };
}

function mockRbacService(overrides: Partial<RbacService> = {}): RbacService {
  return {
    canAccess: vi.fn(async () => true),
    canRunOperation: vi.fn(async () => true),
    getAllowedScope: vi.fn(async () => ({ wildcard: true, names: new Set() })),
    getPermissions: vi.fn(async () => []),
    ...overrides,
  } as unknown as RbacService;
}

describe('McpTokenService.create', () => {
  let tokenRepo: ReturnType<typeof mockTokenRepo>;
  let projectRepo: IProjectRepository;
  let rbacRepo: ReturnType<typeof mockRbacRepo>;
  let rbacService: RbacService;
  let service: McpTokenService;

  beforeEach(() => {
    tokenRepo = mockTokenRepo();
    projectRepo = mockProjectRepo();
    rbacRepo = mockRbacRepo();
    rbacService = mockRbacService();
    service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
  });

  it('creates a token with no bindings (rbacMode=empty, default)', async () => {
    const result = await service.create('cuser1', {
      name: 'mytok',
      projectId: PROJECT.id,
    });
    expect(result.raw).toMatch(new RegExp(`^${TOKEN_PREFIX}`));
    expect(isMcpToken(result.raw)).toBe(true);
    expect(tokenRepo.create).toHaveBeenCalledTimes(1);
    // Hash must be persisted, never raw
    const args = vi.mocked(tokenRepo.create).mock.calls[0]![0];
    expect(args.tokenHash).toBe(hashToken(result.raw));
    expect(args.tokenPrefix).toBe(result.raw.slice(0, 16));
    // No RBAC definition should be created when there are no bindings
    expect(rbacRepo.create).not.toHaveBeenCalled();
  });

  it('creates an RbacDefinition with subject McpToken:<sha> when bindings are given', async () => {
    const result = await service.create('cuser1', {
      name: 'mytok',
      projectId: PROJECT.id,
      bindings: [{ role: 'view', resource: 'servers' }],
    });
    expect(rbacRepo.create).toHaveBeenCalledTimes(1);
    const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
    const subjects = defArgs.subjects as Array<{ kind: string; name: string }>;
    expect(subjects).toEqual([{ kind: 'McpToken', name: hashToken(result.raw) }]);
    expect(defArgs.roleBindings).toEqual([{ role: 'view', resource: 'servers' }]);
  });

  it('rejects bindings the creator does not have (ceiling violation)', async () => {
    rbacService = mockRbacService({
      canAccess: vi.fn(async () => false),
    } as Partial<RbacService>);
    service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);

    await expect(
      service.create('cuser1', {
        name: 'mytok',
        projectId: PROJECT.id,
        bindings: [{ role: 'edit', resource: 'servers' }],
      }),
    ).rejects.toThrow(PermissionCeilingError);
    expect(tokenRepo.create).not.toHaveBeenCalled();
  });

  it('clones the creator\'s permissions when rbacMode=clone', async () => {
    rbacService = mockRbacService({
      getPermissions: vi.fn(async () => [
        { role: 'view', resource: 'servers' },
        { role: 'run', action: 'logs' },
      ]),
    } as Partial<RbacService>);
    service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);

    await service.create('cuser1', {
      name: 'mytok',
      projectId: PROJECT.id,
      rbacMode: 'clone',
    });
    expect(rbacRepo.create).toHaveBeenCalledTimes(1);
    const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
    expect(defArgs.roleBindings).toEqual([
      { role: 'view', resource: 'servers' },
      { role: 'run', action: 'logs' },
    ]);
  });

  it('throws NotFoundError if project does not exist', async () => {
    await expect(
      service.create('cuser1', { name: 'mytok', projectId: 'nope' }),
    ).rejects.toThrow(NotFoundError);
  });

  it('throws ConflictError if active token with same name in same project exists', async () => {
    vi.mocked(tokenRepo.findByNameAndProject).mockResolvedValueOnce(makeRow());
    await expect(
      service.create('cuser1', { name: 'mytok', projectId: PROJECT.id }),
    ).rejects.toThrow(ConflictError);
  });
});

describe('McpTokenService.introspectRaw', () => {
  let tokenRepo: ReturnType<typeof mockTokenRepo>;
  let service: McpTokenService;

  beforeEach(() => {
    tokenRepo = mockTokenRepo();
    service = new McpTokenService(tokenRepo, mockProjectRepo(), mockRbacRepo(), mockRbacService());
  });

  it('returns ok=false for unknown tokens', async () => {
    const result = await service.introspectRaw(`${TOKEN_PREFIX}unknown`);
    expect(result.ok).toBe(false);
    expect(result.tokenName).toBeUndefined();
  });

  it('returns ok=true and principal info for active tokens, and updates lastUsedAt', async () => {
    const raw = `${TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`;
    const hash = hashToken(raw);
    vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hash }));
    const result = await service.introspectRaw(raw);
    expect(result.ok).toBe(true);
    expect(result.projectName).toBe(PROJECT.name);
    expect(result.tokenName).toBe('mytok');
    expect(tokenRepo.touchLastUsed).toHaveBeenCalled();
  });

  it('rejects revoked tokens', async () => {
    const raw = `${TOKEN_PREFIX}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`;
    vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), revokedAt: new Date() }));
    const result = await service.introspectRaw(raw);
    expect(result.ok).toBe(false);
    expect(result.revoked).toBe(true);
  });

  it('rejects expired tokens', async () => {
    const raw = `${TOKEN_PREFIX}cccccccccccccccccccccccccccccccc`;
    const past = new Date(Date.now() - 60_000);
    vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), expiresAt: past }));
    const result = await service.introspectRaw(raw);
    expect(result.ok).toBe(false);
    expect(result.expired).toBe(true);
  });
});

describe('McpTokenService.revoke', () => {
  it('marks revokedAt and removes the auto-created RbacDefinition', async () => {
    const tokenRepo = mockTokenRepo();
    const rbacRepo = mockRbacRepo();
    const service = new McpTokenService(tokenRepo, mockProjectRepo(), rbacRepo, mockRbacService());

    const row = makeRow();
    vi.mocked(tokenRepo.findById).mockResolvedValue(row);
    vi.mocked(rbacRepo.findByName).mockResolvedValue({
      id: 'rbac-ctok1', name: 'mcptoken-ctok1', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
    });

    await service.revoke('ctok1');

    expect(tokenRepo.revoke).toHaveBeenCalledWith('ctok1');
    expect(rbacRepo.findByName).toHaveBeenCalledWith('mcptoken-ctok1');
    expect(rbacRepo.delete).toHaveBeenCalledWith('rbac-ctok1');
  });
});
