/**
 * Unit tests for src/services/DIDService.ts
 * All public methods are tested. pg Pool, node-vault, and Redis are mocked.
 */

import { DIDService } from '../../../src/services/DIDService';
import { AgentNotFoundError } from '../../../src/utils/errors';
import { IPublicKeyJwk } from '../../../src/types/did';

// ─── Mock node-vault ─────────────────────────────────────────────────────────

jest.mock('node-vault', () => {
  return jest.fn(() => ({
    write: jest.fn().mockResolvedValue({}),
    read: jest.fn().mockResolvedValue({}),
  }));
});

// ─── Mock pg Pool ─────────────────────────────────────────────────────────────

const mockQuery = jest.fn();
const mockPool = {
  query: mockQuery,
} as never;

// ─── Mock Redis client ────────────────────────────────────────────────────────

const mockRedisGet = jest.fn();
const mockRedisSet = jest.fn();
const mockRedis = {
  get: mockRedisGet,
  set: mockRedisSet,
} as never;

// ─── Constants ────────────────────────────────────────────────────────────────

const TEST_DOMAIN = 'test.example.com';
const AGENT_ID = 'agt_test';
const ORG_ID = 'org_system';

const MOCK_PUBLIC_KEY_JWK: IPublicKeyJwk = {
  kty: 'EC',
  crv: 'P-256',
  x: 'abc123',
  y: 'def456',
};

const MOCK_AGENT_ROW = {
  agent_id: AGENT_ID,
  organization_id: ORG_ID,
  email: 'test@example.com',
  agent_type: 'orchestrator',
  version: '1.0.0',
  capabilities: ['task-planning'],
  owner: 'acme',
  deployment_env: 'production',
  status: 'active',
  created_at: new Date('2026-01-01T00:00:00Z'),
  updated_at: new Date('2026-01-02T00:00:00Z'),
  did: `did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`,
  did_created_at: new Date('2026-01-01T00:00:00Z'),
  key_id: 'key_test',
  public_key_jwk: MOCK_PUBLIC_KEY_JWK,
  vault_key_path: 'dev:no-vault',
  key_type: 'EC',
  curve: 'P-256',
  key_created_at: new Date('2026-01-01T00:00:00Z'),
};

const MOCK_DECOMMISSIONED_ROW = {
  ...MOCK_AGENT_ROW,
  status: 'decommissioned',
};

/** Row simulating an agent that has no key yet (LEFT JOIN returns NULLs for key columns). */
const MOCK_AGENT_ROW_NO_KEY = {
  agent_id: AGENT_ID,
  organization_id: ORG_ID,
  email: 'test@example.com',
  agent_type: 'orchestrator',
  version: '1.0.0',
  capabilities: ['task-planning'],
  owner: 'acme',
  deployment_env: 'production',
  status: 'active',
  created_at: new Date('2026-01-01T00:00:00Z'),
  updated_at: new Date('2026-01-02T00:00:00Z'),
  did: null,
  did_created_at: null,
  key_id: null,
  public_key_jwk: null,
  vault_key_path: null,
  key_type: null,
  curve: null,
  key_created_at: null,
};

// ─── Helper ───────────────────────────────────────────────────────────────────

function buildService(): DIDService {
  return new DIDService(mockPool, null, mockRedis);
}

/** Reset all mocks to clean state before each test. */
function resetMocks(): void {
  jest.clearAllMocks();
  mockRedisGet.mockResolvedValue(null);
  mockRedisSet.mockResolvedValue('OK');
  // Default pool.query returns empty result (agent not found)
  mockQuery.mockResolvedValue({ rows: [] });
}

// ─── Suite ────────────────────────────────────────────────────────────────────

describe('DIDService', () => {
  beforeAll(() => {
    process.env['DID_WEB_DOMAIN'] = TEST_DOMAIN;
  });

  afterAll(() => {
    delete process.env['DID_WEB_DOMAIN'];
    delete process.env['VAULT_ADDR'];
    delete process.env['VAULT_TOKEN'];
  });

  beforeEach(() => {
    resetMocks();
  });

  // ─── generateDIDForAgent ──────────────────────────────────────────────────

  describe('generateDIDForAgent()', () => {
    it('should return a did:web DID and public key JWK', async () => {
      mockQuery
        .mockResolvedValueOnce({ rows: [] }) // INSERT agent_did_keys
        .mockResolvedValueOnce({ rows: [] }); // UPDATE agents

      const service = buildService();
      const { did, publicKeyJwk } = await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      expect(did).toBe(`did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`);
      expect(publicKeyJwk).toBeDefined();
      expect(publicKeyJwk.kty).toBe('EC');
      expect(publicKeyJwk.crv).toBe('P-256');
    });

    it('should call pool.query twice (INSERT + UPDATE)', async () => {
      mockQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [] });

      const service = buildService();
      await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      expect(mockQuery).toHaveBeenCalledTimes(2);
    });

    it('should INSERT into agent_did_keys with correct columns', async () => {
      mockQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [] });

      const service = buildService();
      await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      const insertCall = mockQuery.mock.calls[0] as [string, unknown[]];
      expect(insertCall[0]).toContain('INSERT INTO agent_did_keys');
      expect(insertCall[1]).toContain(AGENT_ID);
      expect(insertCall[1]).toContain(ORG_ID);
    });

    it('should UPDATE agents table with the generated DID', async () => {
      mockQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [] });

      const service = buildService();
      const { did } = await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      const updateCall = mockQuery.mock.calls[1] as [string, unknown[]];
      expect(updateCall[0]).toContain('UPDATE agents');
      expect(updateCall[1]).toContain(did);
      expect(updateCall[1]).toContain(AGENT_ID);
    });

    it('should use dev:no-vault marker when Vault env vars are not set', async () => {
      delete process.env['VAULT_ADDR'];
      delete process.env['VAULT_TOKEN'];
      mockQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [] });

      const service = buildService();
      await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      const insertCall = mockQuery.mock.calls[0] as [string, unknown[]];
      // vault_key_path is the 5th param ($5)
      expect(insertCall[1][4]).toBe('dev:no-vault');
    });

    it('should call vault.write when VAULT_ADDR and VAULT_TOKEN are set', async () => {
      process.env['VAULT_ADDR'] = 'http://localhost:8200';
      process.env['VAULT_TOKEN'] = 'test-token';

      const nodeVault = require('node-vault');
      const mockVaultInstance = { write: jest.fn().mockResolvedValue({}) };
      (nodeVault as jest.Mock).mockReturnValueOnce(mockVaultInstance);

      mockQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [] });

      const service = buildService();
      await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      expect(mockVaultInstance.write).toHaveBeenCalledTimes(1);
      const [vaultPath, payload] = mockVaultInstance.write.mock.calls[0] as [
        string,
        { data: { privateKeyPem: string } },
      ];
      expect(vaultPath).toContain(AGENT_ID);
      expect(payload.data.privateKeyPem).toBeDefined();
      expect(payload.data.privateKeyPem).toContain('-----BEGIN');

      delete process.env['VAULT_ADDR'];
      delete process.env['VAULT_TOKEN'];
    });

    it('should NEVER include the private key PEM in the returned object', async () => {
      mockQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [] });

      const service = buildService();
      const result = await service.generateDIDForAgent(AGENT_ID, ORG_ID);

      const serialised = JSON.stringify(result);
      expect(serialised).not.toContain('privateKeyPem');
      expect(serialised).not.toContain('PRIVATE KEY');
    });
  });

  // ─── buildInstanceDIDDocument ─────────────────────────────────────────────

  describe('buildInstanceDIDDocument()', () => {
    it('should return a W3C DID Document with correct @context', async () => {
      const service = buildService();
      const doc = await service.buildInstanceDIDDocument();

      expect(doc['@context']).toContain('https://www.w3.org/ns/did/v1');
    });

    it('should set the DID id to did:web:{domain}', async () => {
      const service = buildService();
      const doc = await service.buildInstanceDIDDocument();

      expect(doc.id).toBe(`did:web:${TEST_DOMAIN}`);
    });

    it('should include a verificationMethod of type JsonWebKey2020', async () => {
      const service = buildService();
      const doc = await service.buildInstanceDIDDocument();

      expect(doc.verificationMethod).toHaveLength(1);
      expect(doc.verificationMethod[0].type).toBe('JsonWebKey2020');
      expect(doc.verificationMethod[0].controller).toBe(`did:web:${TEST_DOMAIN}`);
    });

    it('should include an AgentIdentityProvider service endpoint', async () => {
      const service = buildService();
      const doc = await service.buildInstanceDIDDocument();

      expect(doc.service).toBeDefined();
      const svc = doc.service![0];
      expect(svc.type).toBe('AgentIdentityProvider');
      expect(svc.serviceEndpoint).toContain(TEST_DOMAIN);
    });

    it('should cache the document in Redis on first call', async () => {
      const service = buildService();
      await service.buildInstanceDIDDocument();

      expect(mockRedisSet).toHaveBeenCalledWith(
        'did:doc:instance',
        expect.any(String),
        { EX: expect.any(Number) },
      );
    });

    it('should return cached document on second call without storing again', async () => {
      const service = buildService();

      // First call — cache miss, builds and stores
      const doc = await service.buildInstanceDIDDocument();

      // Reset and simulate cache hit on second call
      resetMocks();
      mockRedisGet.mockResolvedValueOnce(JSON.stringify(doc));

      await service.buildInstanceDIDDocument();

      // Only called once total for the second call
      expect(mockRedisGet).toHaveBeenCalledTimes(1);
      // set should NOT be called on cache hit
      expect(mockRedisSet).not.toHaveBeenCalled();
    });

    it('should throw an error when DID_WEB_DOMAIN is not set', async () => {
      delete process.env['DID_WEB_DOMAIN'];
      const service = buildService();
      await expect(service.buildInstanceDIDDocument()).rejects.toThrow(
        'DID_WEB_DOMAIN environment variable is required',
      );
      process.env['DID_WEB_DOMAIN'] = TEST_DOMAIN;
    });
  });

  // ─── buildAgentDIDDocument ────────────────────────────────────────────────

  describe('buildAgentDIDDocument()', () => {
    it('should return a DID Document for an active agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const result = await service.buildAgentDIDDocument(AGENT_ID);

      expect(result.deactivated).toBe(false);
      expect(result.document.id).toBe(`did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`);
    });

    it('should include DID contexts for active agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const { document } = await service.buildAgentDIDDocument(AGENT_ID);

      expect(document['@context']).toContain('https://www.w3.org/ns/did/v1');
      expect(document['@context']).toContain('https://w3id.org/agntcy/v1');
    });

    it('should include the public key in verificationMethod', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const { document } = await service.buildAgentDIDDocument(AGENT_ID);

      expect(document.verificationMethod).toHaveLength(1);
      expect(document.verificationMethod[0].publicKeyJwk).toEqual(MOCK_PUBLIC_KEY_JWK);
    });

    it('should include AGNTCY extension with agent fields', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const { document } = await service.buildAgentDIDDocument(AGENT_ID);

      expect(document.agntcy).toBeDefined();
      expect(document.agntcy!.agentId).toBe(AGENT_ID);
      expect(document.agntcy!.agentType).toBe('orchestrator');
      expect(document.agntcy!.capabilities).toEqual(['task-planning']);
    });

    it('should return deactivated=true for a decommissioned agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });

      const service = buildService();
      const result = await service.buildAgentDIDDocument(AGENT_ID);

      expect(result.deactivated).toBe(true);
    });

    it('should include AgentStatus service endpoint for decommissioned agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });

      const service = buildService();
      const { document } = await service.buildAgentDIDDocument(AGENT_ID);

      expect(document.service).toBeDefined();
      const statusSvc = document.service!.find((s) => s.type === 'AgentStatus');
      expect(statusSvc).toBeDefined();
      expect(statusSvc!.serviceEndpoint).toBe('decommissioned');
    });

    it('should NOT cache document for decommissioned agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });

      const service = buildService();
      await service.buildAgentDIDDocument(AGENT_ID);

      expect(mockRedisSet).not.toHaveBeenCalled();
    });

    it('should cache document for active agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      await service.buildAgentDIDDocument(AGENT_ID);

      expect(mockRedisSet).toHaveBeenCalledWith(
        `did:doc:${AGENT_ID}`,
        expect.any(String),
        { EX: expect.any(Number) },
      );
    });

    it('should use cached document on second call for active agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const { document: firstDoc } = await service.buildAgentDIDDocument(AGENT_ID);

      // Reset and simulate cache hit for second call
      resetMocks();
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });
      mockRedisGet.mockResolvedValueOnce(JSON.stringify(firstDoc));

      const { document: secondDoc } = await service.buildAgentDIDDocument(AGENT_ID);

      expect(secondDoc).toEqual(firstDoc);
      // set should NOT be called on cache hit
      expect(mockRedisSet).not.toHaveBeenCalled();
    });

    it('should throw AgentNotFoundError when agent does not exist', async () => {
      // Default mock already returns { rows: [] } from resetMocks
      const service = buildService();
      await expect(service.buildAgentDIDDocument('nonexistent-id')).rejects.toThrow(
        AgentNotFoundError,
      );
    });

    it('should handle agent with no key (empty verificationMethod)', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW_NO_KEY] });

      const service = buildService();
      const { document } = await service.buildAgentDIDDocument(AGENT_ID);

      expect(document.verificationMethod).toHaveLength(0);
      expect(document.authentication).toHaveLength(0);
      expect(document.assertionMethod).toHaveLength(0);
    });

    it('should NEVER include private key material in the DID Document', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const { document } = await service.buildAgentDIDDocument(AGENT_ID);

      const serialised = JSON.stringify(document);
      expect(serialised).not.toContain('privateKeyPem');
      expect(serialised).not.toContain('PRIVATE KEY');
    });
  });

  // ─── buildResolutionResult ────────────────────────────────────────────────

  describe('buildResolutionResult()', () => {
    it('should return a W3C DID Resolution result with all required sections', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const result = await service.buildResolutionResult(AGENT_ID);

      expect(result.didDocument).toBeDefined();
      expect(result.didDocumentMetadata).toBeDefined();
      expect(result.didResolutionMetadata).toBeDefined();
    });

    it('should set deactivated=false in metadata for active agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const result = await service.buildResolutionResult(AGENT_ID);

      expect(result.didDocumentMetadata.deactivated).toBe(false);
    });

    it('should set deactivated=true in metadata for decommissioned agent', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_DECOMMISSIONED_ROW] });

      const service = buildService();
      const result = await service.buildResolutionResult(AGENT_ID);

      expect(result.didDocumentMetadata.deactivated).toBe(true);
    });

    it('should set contentType to application/did+ld+json in resolutionMetadata', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const result = await service.buildResolutionResult(AGENT_ID);

      expect(result.didResolutionMetadata.contentType).toBe('application/did+ld+json');
    });

    it('should include ISO timestamps for created, updated, and retrieved', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const result = await service.buildResolutionResult(AGENT_ID);

      expect(() => new Date(result.didDocumentMetadata.created)).not.toThrow();
      expect(() => new Date(result.didDocumentMetadata.updated)).not.toThrow();
      expect(() => new Date(result.didResolutionMetadata.retrieved)).not.toThrow();
    });

    it('should throw AgentNotFoundError when agent does not exist', async () => {
      // Default mock already returns { rows: [] } from resetMocks
      const service = buildService();
      await expect(service.buildResolutionResult('nonexistent-id')).rejects.toThrow(
        AgentNotFoundError,
      );
    });

    it('should NEVER include private key material in the resolution result', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const result = await service.buildResolutionResult(AGENT_ID);

      const serialised = JSON.stringify(result);
      expect(serialised).not.toContain('privateKeyPem');
      expect(serialised).not.toContain('PRIVATE KEY');
    });
  });

  // ─── buildAgentCard ───────────────────────────────────────────────────────

  describe('buildAgentCard()', () => {
    it('should return an AGNTCY agent card with correct fields', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const card = await service.buildAgentCard(AGENT_ID);

      expect(card.did).toBe(`did:web:${TEST_DOMAIN}:agents:${AGENT_ID}`);
      expect(card.name).toBe('test@example.com');
      expect(card.agentType).toBe('orchestrator');
      expect(card.capabilities).toEqual(['task-planning']);
      expect(card.owner).toBe('acme');
      expect(card.version).toBe('1.0.0');
      expect(card.deploymentEnv).toBe('production');
      expect(card.identityProvider).toBe('https://idp.sentryagent.ai');
    });

    it('should include a valid ISO issuedAt timestamp', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const card = await service.buildAgentCard(AGENT_ID);

      expect(() => new Date(card.issuedAt)).not.toThrow();
      expect(new Date(card.issuedAt).toISOString()).toBe(card.issuedAt);
    });

    it('should use agent.createdAt as issuedAt when key_created_at is null', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW_NO_KEY] });

      const service = buildService();
      const card = await service.buildAgentCard(AGENT_ID);

      expect(card.issuedAt).toBe(MOCK_AGENT_ROW_NO_KEY.created_at.toISOString());
    });

    it('should use key_created_at as issuedAt when present', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const card = await service.buildAgentCard(AGENT_ID);

      expect(card.issuedAt).toBe(MOCK_AGENT_ROW.key_created_at.toISOString());
    });

    it('should throw AgentNotFoundError when agent does not exist', async () => {
      // Default mock already returns { rows: [] } from resetMocks
      const service = buildService();
      await expect(service.buildAgentCard('nonexistent-id')).rejects.toThrow(AgentNotFoundError);
    });

    it('should NEVER include private key material in the agent card', async () => {
      mockQuery.mockResolvedValueOnce({ rows: [MOCK_AGENT_ROW] });

      const service = buildService();
      const card = await service.buildAgentCard(AGENT_ID);

      const serialised = JSON.stringify(card);
      expect(serialised).not.toContain('privateKeyPem');
      expect(serialised).not.toContain('PRIVATE KEY');
    });
  });
});
