import { describe, it, expect, vi } from 'vitest';
import { SecretBackendRotator } from '../src/services/secret-backend-rotator.service.js';
import type { SecretBackend, Secret } from '@prisma/client';

function makeBackend(overrides: Partial<SecretBackend> = {}): SecretBackend {
  return {
    id: 'backend-1',
    name: 'bao',
    type: 'openbao',
    config: {
      url: 'http://bao.example:8200',
      auth: 'token',
      mount: 'secret',
      pathPrefix: 'mcpd',
      tokenSecretRef: { name: 'bao-creds', key: 'token' },
      rotation: { enabled: true, tokenRole: 'app-mcpd-role', intervalHours: 24 },
    } as unknown as SecretBackend['config'],
    tokenMeta: { rotatable: true } as unknown as SecretBackend['tokenMeta'],
    isDefault: false,
    description: '',
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function makeSecret(overrides: Partial<Secret> = {}): Secret {
  return {
    id: 'sec-1',
    name: 'bao-creds',
    backendId: 'backend-plaintext',
    data: { token: 'old.token.value' },
    externalRef: '',
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

interface MockState {
  backend: SecretBackend;
  secret: Secret;
  secretData: Record<string, string>;
  lastTokenMeta: Record<string, unknown> | null;
  lastSecretUpdate: Record<string, unknown> | null;
}

function mockDeps(state: MockState, vaultResponses: Array<{ match: RegExp; status: number; body?: unknown }>) {
  const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => {
    const key = `${init?.method ?? 'GET'} ${String(url)}`;
    const match = vaultResponses.find((r) => r.match.test(key) || r.match.test(String(url)));
    if (!match) throw new Error(`unexpected vault call: ${key}`);
    const body = match.body !== undefined ? JSON.stringify(match.body) : '';
    return new Response(body, { status: match.status });
  });

  const backends = {
    getById: vi.fn(async (id: string) => {
      if (id === state.backend.id) return state.backend;
      throw new Error(`not found: ${id}`);
    }),
    updateTokenMeta: vi.fn(async (id: string, meta: Record<string, unknown>) => {
      expect(id).toBe(state.backend.id);
      state.lastTokenMeta = meta;
      state.backend = { ...state.backend, tokenMeta: meta as unknown as SecretBackend['tokenMeta'] };
      return state.backend;
    }),
  };

  const secrets = {
    getByName: vi.fn(async (name: string) => {
      if (name === state.secret.name) return state.secret;
      throw new Error(`secret not found: ${name}`);
    }),
    resolveData: vi.fn(async () => ({ ...state.secretData })),
    update: vi.fn(async (id: string, input: { data: Record<string, string> }) => {
      expect(id).toBe(state.secret.id);
      state.secretData = { ...input.data };
      state.lastSecretUpdate = input as unknown as Record<string, unknown>;
      return state.secret;
    }),
  };

  return { fetchFn, backends, secrets };
}

describe('SecretBackendRotator', () => {
  it('isRotatable: true for wizard-provisioned openbao', () => {
    const state: MockState = {
      backend: makeBackend(),
      secret: makeSecret(),
      secretData: { token: 'x' },
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { backends, secrets } = mockDeps(state, []);
    const r = new SecretBackendRotator({
      backends: backends as unknown as Parameters<typeof SecretBackendRotator.prototype.rotateOne>[0] extends never ? never : never,
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } as any);
    // Use a real rotator with both deps filled.
    const rotator = new SecretBackendRotator({
      backends: backends as never,
      secrets: secrets as never,
    });
    expect(rotator.isRotatable(state.backend)).toBe(true);
    expect(r).toBeDefined();
  });

  it('isRotatable: false for kubernetes-auth openbao', () => {
    const state: MockState = {
      backend: makeBackend({
        config: {
          url: 'http://bao', auth: 'kubernetes', role: 'r',
          rotation: { enabled: true, tokenRole: 'app-mcpd-role' },
        } as unknown as SecretBackend['config'],
      }),
      secret: makeSecret(),
      secretData: {},
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { backends, secrets } = mockDeps(state, []);
    const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never });
    expect(rotator.isRotatable(state.backend)).toBe(false);
  });

  it('rotateOne: mints → verifies → persists → revokes old → updates tokenMeta', async () => {
    const state: MockState = {
      backend: makeBackend({ tokenMeta: { rotatable: true, currentAccessor: 'old-accessor' } as unknown as SecretBackend['tokenMeta'] }),
      secret: makeSecret({ data: { token: 'old.token.value' } as Secret['data'] }),
      secretData: { token: 'old.token.value' },
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { fetchFn, backends, secrets } = mockDeps(state, [
      { match: /POST .*auth\/token\/create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new.token.value', accessor: 'new-accessor', lease_duration: 720 * 3600, renewable: true } } },
      { match: /GET .*auth\/token\/lookup-self$/, status: 200, body: { data: { accessor: 'new-accessor', ttl: 720 * 3600 } } },
      { match: /POST .*auth\/token\/revoke-accessor$/, status: 200 },
    ]);

    const rotator = new SecretBackendRotator({
      backends: backends as never,
      secrets: secrets as never,
      fetch: fetchFn as unknown as typeof fetch,
      now: () => new Date('2026-04-20T10:00:00Z'),
    });

    const meta = await rotator.rotateOne(state.backend.id);

    // Correct order of HTTP calls: create (with OLD token) → lookup (with NEW token) → revoke (with NEW token)
    const calls = fetchFn.mock.calls.map((c) => `${(c[1] as RequestInit).method ?? 'GET'} ${String(c[0])}`);
    expect(calls[0]).toMatch(/POST .*create\/app-mcpd-role/);
    expect(calls[1]).toMatch(/GET .*lookup-self/);
    expect(calls[2]).toMatch(/POST .*revoke-accessor/);
    expect((fetchFn.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'old.token.value' });
    expect((fetchFn.mock.calls[1]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'new.token.value' });
    expect((fetchFn.mock.calls[2]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'new.token.value' });

    // Secret was updated BEFORE revoke — state reflects ordering by sequence above.
    expect(state.secretData.token).toBe('new.token.value');

    // tokenMeta carries fresh timestamps + accessor
    expect(meta.currentAccessor).toBe('new-accessor');
    expect(meta.lastRotationError).toBeNull();
    expect(meta.generatedAt).toBe('2026-04-20T10:00:00.000Z');
    expect(meta.nextRenewalAt).toBe('2026-04-21T10:00:00.000Z');
    expect(meta.validUntil).toBe('2026-05-20T10:00:00.000Z');
    expect(state.lastTokenMeta?.rotatable).toBe(true);
  });

  it('rotateOne: on mint failure, records lastRotationError and keeps old token', async () => {
    const state: MockState = {
      backend: makeBackend(),
      secret: makeSecret({ data: { token: 'old.token' } as Secret['data'] }),
      secretData: { token: 'old.token' },
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { fetchFn, backends, secrets } = mockDeps(state, [
      { match: /create\/app-mcpd-role$/, status: 403, body: { errors: ['permission denied'] } },
    ]);
    const rotator = new SecretBackendRotator({
      backends: backends as never,
      secrets: secrets as never,
      fetch: fetchFn as unknown as typeof fetch,
    });

    await expect(rotator.rotateOne(state.backend.id)).rejects.toThrow(/HTTP 403/);

    // Secret was NOT updated
    expect(state.secretData.token).toBe('old.token');
    expect(secrets.update).not.toHaveBeenCalled();
    // tokenMeta records the error
    expect(state.lastTokenMeta?.lastRotationError).toMatch(/HTTP 403/);
  });

  it('rotateOne: rejects when minted token is not renewable', async () => {
    const state: MockState = {
      backend: makeBackend(),
      secret: makeSecret({ data: { token: 'old' } as Secret['data'] }),
      secretData: { token: 'old' },
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { fetchFn, backends, secrets } = mockDeps(state, [
      { match: /create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new', accessor: 'a', lease_duration: 100, renewable: false } } },
    ]);
    const rotator = new SecretBackendRotator({
      backends: backends as never,
      secrets: secrets as never,
      fetch: fetchFn as unknown as typeof fetch,
    });
    await expect(rotator.rotateOne(state.backend.id)).rejects.toThrow(/not renewable/);
    expect(state.secretData.token).toBe('old');
  });

  it('rotateOne: continues despite revoke-accessor failure (old token expires anyway)', async () => {
    const state: MockState = {
      backend: makeBackend({ tokenMeta: { rotatable: true, currentAccessor: 'old-accessor' } as unknown as SecretBackend['tokenMeta'] }),
      secret: makeSecret({ data: { token: 'old' } as Secret['data'] }),
      secretData: { token: 'old' },
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { fetchFn, backends, secrets } = mockDeps(state, [
      { match: /create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new', accessor: 'new-a', lease_duration: 3600, renewable: true } } },
      { match: /lookup-self$/, status: 200, body: { data: { accessor: 'new-a', ttl: 3600 } } },
      { match: /revoke-accessor$/, status: 502 },
    ]);
    const rotator = new SecretBackendRotator({
      backends: backends as never,
      secrets: secrets as never,
      fetch: fetchFn as unknown as typeof fetch,
    });
    const meta = await rotator.rotateOne(state.backend.id);
    expect(state.secretData.token).toBe('new');
    expect(meta.lastRotationError).toBeNull();
  });

  it('isOverdue: true when lastRotationAt missing or >24h old', () => {
    const state: MockState = {
      backend: makeBackend({ tokenMeta: { rotatable: true } as unknown as SecretBackend['tokenMeta'] }),
      secret: makeSecret(),
      secretData: {},
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { backends, secrets } = mockDeps(state, []);
    const now = () => new Date('2026-04-20T10:00:00Z');
    const r = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, now });

    expect(r.isOverdue(state.backend)).toBe(true);

    const fresh = { ...state.backend, tokenMeta: { rotatable: true, lastRotationAt: '2026-04-20T09:00:00Z' } as unknown as SecretBackend['tokenMeta'] };
    expect(r.isOverdue(fresh)).toBe(false);

    const stale = { ...state.backend, tokenMeta: { rotatable: true, lastRotationAt: '2026-04-18T10:00:00Z' } as unknown as SecretBackend['tokenMeta'] };
    expect(r.isOverdue(stale)).toBe(true);
  });

  it('rotateOne: throws when backend is not rotatable', async () => {
    const state: MockState = {
      backend: makeBackend({ type: 'plaintext', config: {} as SecretBackend['config'] }),
      secret: makeSecret(),
      secretData: {},
      lastTokenMeta: null,
      lastSecretUpdate: null,
    };
    const { backends, secrets } = mockDeps(state, []);
    const r = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never });
    await expect(r.rotateOne(state.backend.id)).rejects.toThrow(/not rotatable/);
  });
});
