/**
 * Unit tests for CaService — Step-CA client (FED-M2-04).
 *
 * Coverage:
 *  - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber
 *  - certChainPem fallback: falls back to certPem when certChain absent
 *  - certChainPem from ca field: uses crt+ca when certChain absent but ca present
 *  - HTTP 401: throws CaServiceError with cause + remediation
 *  - HTTP non-401 error: throws CaServiceError
 *  - Malformed CSR: throws before HTTP call (INVALID_CSR)
 *  - Non-JSON response: throws CaServiceError
 *  - HTTPS connection error: throws CaServiceError
 *  - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
 *    verified with jose.jwtVerify (real signature check)
 *  - CaServiceError: has cause + remediation properties
 *  - Missing crt in response: throws CaServiceError
 *  - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR
 *  - provisionerPassword never appears in CaServiceError messages
 *  - HTTPS-only enforcement: http:// URL throws in constructor
 */

import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, beforeAll, type Mock } from 'vitest';
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
import { makeMosaicIssuedCert } from './__tests__/helpers/test-cert.js';

// ---------------------------------------------------------------------------
// Mock node:https BEFORE importing CaService so the mock is in place when
// the module is loaded. Vitest/ESM require vi.mock at the top level.
// ---------------------------------------------------------------------------

vi.mock('node:https', () => {
  const mockRequest = vi.fn();
  const mockAgent = vi.fn().mockImplementation(() => ({}));
  return {
    default: { request: mockRequest, Agent: mockAgent },
    request: mockRequest,
    Agent: mockAgent,
  };
});

vi.mock('node:fs', () => {
  const mockReadFileSync = vi
    .fn()
    .mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n');
  return {
    default: { readFileSync: mockReadFileSync },
    readFileSync: mockReadFileSync,
  };
});

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// Real self-signed EC P-256 certificate generated with openssl for testing.
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \
//   -out /dev/stdout -subj "/CN=test" -days 1
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw
DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla
MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL
n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4
MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw
HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB
/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm
zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw==
-----END CERTIFICATE-----
`;

// Use a second copy of the same cert for the CA field in tests.
const FAKE_CA_PEM = FAKE_CERT_PEM;

const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';

// Real self-signed cert containing both Mosaic OID extensions — populated in beforeAll.
// Required because CaService.issueCert performs CRIT-1 OID presence/value checks on the
// response cert (PR #501 — strict parsing, no silent fallback).
let realIssuedCertPem: string;

// ---------------------------------------------------------------------------
// Generate a real EC P-256 key pair and CSR for integration-style tests
// ---------------------------------------------------------------------------

// We generate this once at module level so it's available to all tests.
// The key pair and CSR PEM are populated asynchronously in the test that needs them.

let realCsrPem: string;

async function generateRealCsr(): Promise<string> {
  const { privateKey, publicKey } = await generateKeyPair('ES256');
  // Export public key JWK for potential verification (not used here but confirms key is exportable)
  await exportJWK(publicKey);

  // Use @peculiar/x509 to build a proper CSR
  const csr = await Pkcs10CertificateRequestGenerator.create({
    name: 'CN=test.federation.local',
    signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
    keys: { privateKey, publicKey },
  });

  return csr.toString('pem');
}

// ---------------------------------------------------------------------------
// Setup env before importing service
// We use an EC P-256 key pair here so the JWK-based signing works.
// The key pair is generated once and stored in module-level vars.
// ---------------------------------------------------------------------------

// Real EC P-256 test JWK (test-only, never used in production).
// Generated with node webcrypto for use in unit tests.
const TEST_EC_PRIVATE_JWK = {
  key_ops: ['sign'],
  ext: true,
  kty: 'EC',
  x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
  y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
  crv: 'P-256',
  d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8',
  kid: 'test-ec-kid',
};

const TEST_EC_PUBLIC_JWK = {
  key_ops: ['verify'],
  ext: true,
  kty: 'EC',
  x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
  y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
  crv: 'P-256',
  kid: 'test-ec-kid',
};

process.env['STEP_CA_URL'] = 'https://step-ca:9000';
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK);
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';

// Import AFTER env is set and mocks are registered
import * as httpsModule from 'node:https';
import { CaService, CaServiceError } from './ca.service.js';
import type { IssueCertRequestDto } from './ca.dto.js';

// ---------------------------------------------------------------------------
// Helper to build a mock https.request that simulates step-ca
// ---------------------------------------------------------------------------

function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void {
  const mockReq = {
    write: vi.fn(),
    end: vi.fn(),
    on: vi.fn(),
    setTimeout: vi.fn(),
  };

  (httpsModule.request as unknown as Mock).mockImplementation(
    (
      _options: unknown,
      callback: (res: {
        statusCode: number;
        on: (event: string, cb: (chunk?: Buffer) => void) => void;
      }) => void,
    ) => {
      const mockRes = {
        statusCode,
        on: (event: string, cb: (chunk?: Buffer) => void) => {
          if (event === 'data') {
            if (body !== undefined) {
              cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
            }
          }
          if (event === 'end') {
            cb();
          }
        },
      };

      if (errorMsg) {
        // Simulate a connection error via the req.on('error') handler
        mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => {
          if (event === 'error') {
            setImmediate(() => cb(new Error(errorMsg)));
          }
        });
      } else {
        // Normal flow: call the response callback
        setImmediate(() => callback(mockRes));
      }

      return mockReq;
    },
  );
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

  beforeAll(async () => {
    // Generate a cert with the two Mosaic OIDs so that CaService.issueCert's
    // CRIT-1 OID checks pass when mock step-ca returns it as `crt`.
    realIssuedCertPem = await makeMosaicIssuedCert({
      grantId: GRANT_ID,
      subjectUserId: SUBJECT_USER_ID,
    });
  });

  beforeEach(() => {
    vi.clearAllMocks();
    service = new CaService();
  });

  function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
    // Use a real CSR if available; fall back to a minimal placeholder
    const defaultCsr = realCsrPem ?? makeFakeCsr();
    return {
      csrPem: defaultCsr,
      grantId: GRANT_ID,
      subjectUserId: SUBJECT_USER_ID,
      ttlSeconds: 300,
      ...overrides,
    };
  }

  function makeFakeCsr(): string {
    // A structurally valid-looking CSR header/footer (body will fail crypto verify)
    return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`;
  }

  // -------------------------------------------------------------------------
  // Real CSR generation — runs once and populates realCsrPem
  // -------------------------------------------------------------------------

  it('generates a real P-256 CSR that passes validateCsr', async () => {
    realCsrPem = await generateRealCsr();
    expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/);

    // Now test that the service's validateCsr accepts it.
    // We call it indirectly via issueCert with a successful mock.
    makeHttpsMock(200, { crt: realIssuedCertPem, certChain: [realIssuedCertPem, FAKE_CA_PEM] });
    const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
    expect(result.certPem).toBe(realIssuedCertPem);
  });

  it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
    const malformedCsr =
      '-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n';

    await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy(
      (err: unknown) => {
        if (!(err instanceof CaServiceError)) return false;
        expect(err.code).toBe('INVALID_CSR');
        return true;
      },
    );
  });

  // -------------------------------------------------------------------------
  // Happy path
  // -------------------------------------------------------------------------

  it('returns IssuedCertDto on success (certChain present)', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(200, {
      crt: realIssuedCertPem,
      certChain: [realIssuedCertPem, FAKE_CA_PEM],
    });

    const result = await service.issueCert(makeReq());

    expect(result.certPem).toBe(realIssuedCertPem);
    expect(result.certChainPem).toContain(realIssuedCertPem);
    expect(result.certChainPem).toContain(FAKE_CA_PEM);
    expect(typeof result.serialNumber).toBe('string');
  });

  // -------------------------------------------------------------------------
  // certChainPem fallback — certChain absent, ca field present
  // -------------------------------------------------------------------------

  it('builds certChainPem from crt+ca when certChain is absent', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(200, {
      crt: realIssuedCertPem,
      ca: FAKE_CA_PEM,
    });

    const result = await service.issueCert(makeReq());

    expect(result.certPem).toBe(realIssuedCertPem);
    expect(result.certChainPem).toContain(realIssuedCertPem);
    expect(result.certChainPem).toContain(FAKE_CA_PEM);
  });

  // -------------------------------------------------------------------------
  // certChainPem fallback — no certChain, no ca field
  // -------------------------------------------------------------------------

  it('falls back to certPem alone when certChain and ca are absent', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(200, { crt: realIssuedCertPem });

    const result = await service.issueCert(makeReq());

    expect(result.certPem).toBe(realIssuedCertPem);
    expect(result.certChainPem).toBe(realIssuedCertPem);
  });

  // -------------------------------------------------------------------------
  // HTTP 401
  // -------------------------------------------------------------------------

  it('throws CaServiceError on HTTP 401', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(401, { message: 'Unauthorized' });

    await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
      if (!(err instanceof CaServiceError)) return false;
      expect(err.message).toMatch(/401/);
      expect(err.remediation).toBeTruthy();
      return true;
    });
  });

  // -------------------------------------------------------------------------
  // HTTP non-401 error (e.g. 422)
  // -------------------------------------------------------------------------

  it('throws CaServiceError on HTTP 422', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(422, { message: 'Unprocessable Entity' });

    await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
  });

  // -------------------------------------------------------------------------
  // Malformed CSR — throws before HTTP call
  // -------------------------------------------------------------------------

  it('throws CaServiceError for malformed CSR without making HTTP call', async () => {
    const requestSpy = vi.spyOn(httpsModule, 'request');

    await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf(
      CaServiceError,
    );

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

  // -------------------------------------------------------------------------
  // Non-JSON response
  // -------------------------------------------------------------------------

  it('throws CaServiceError when step-ca returns non-JSON', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(200, 'this is not json');

    await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
      if (!(err instanceof CaServiceError)) return false;
      expect(err.message).toMatch(/non-JSON/);
      return true;
    });
  });

  // -------------------------------------------------------------------------
  // HTTPS connection error
  // -------------------------------------------------------------------------

  it('throws CaServiceError on HTTPS connection error', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');

    await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
      if (!(err instanceof CaServiceError)) return false;
      expect(err.message).toMatch(/HTTPS connection/);
      expect(err.cause).toBeInstanceOf(Error);
      return true;
    });
  });

  // -------------------------------------------------------------------------
  // JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
  // Verified with jose.jwtVerify for real signature verification (M6)
  // -------------------------------------------------------------------------

  it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();

    let capturedBody: Record<string, unknown> | undefined;

    const mockReq = {
      write: vi.fn((data: string) => {
        capturedBody = JSON.parse(data) as Record<string, unknown>;
      }),
      end: vi.fn(),
      on: vi.fn(),
      setTimeout: vi.fn(),
    };

    (httpsModule.request as unknown as Mock).mockImplementation(
      (
        _options: unknown,
        callback: (res: {
          statusCode: number;
          on: (event: string, cb: (chunk?: Buffer) => void) => void;
        }) => void,
      ) => {
        const mockRes = {
          statusCode: 200,
          on: (event: string, cb: (chunk?: Buffer) => void) => {
            if (event === 'data') {
              cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem })));
            }
            if (event === 'end') {
              cb();
            }
          },
        };
        setImmediate(() => callback(mockRes));
        return mockReq;
      },
    );

    await service.issueCert(makeReq({ csrPem: realCsrPem }));

    expect(capturedBody).toBeDefined();
    const ott = capturedBody!['ott'] as string;
    expect(typeof ott).toBe('string');

    // Verify JWT structure
    const parts = ott.split('.');
    expect(parts).toHaveLength(3);

    // Decode payload without signature check first
    const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
    const payload = JSON.parse(payloadJson) as Record<string, unknown>;

    expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
    expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
    expect(typeof payload['jti']).toBe('string'); // M2: jti present
    expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format

    // M3: top-level sha should NOT be present; step.sha should be present
    expect(payload['sha']).toBeUndefined();
    const step = payload['step'] as Record<string, unknown> | undefined;
    expect(step?.['sha']).toBeDefined();

    // M6: Verify signature with jose.jwtVerify using the public key
    const { importJWK: importJose } = await import('jose');
    const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256');
    const verified = await jwtVerify(ott, publicKey);
    expect(verified.payload['mosaic_grant_id']).toBe(GRANT_ID);
  });

  // -------------------------------------------------------------------------
  // CaServiceError has cause + remediation
  // -------------------------------------------------------------------------

  it('CaServiceError carries cause and remediation', () => {
    const cause = new Error('original error');
    const err = new CaServiceError('something went wrong', 'fix it like this', cause);

    expect(err).toBeInstanceOf(Error);
    expect(err).toBeInstanceOf(CaServiceError);
    expect(err.message).toBe('something went wrong');
    expect(err.remediation).toBe('fix it like this');
    expect(err.cause).toBe(cause);
    expect(err.name).toBe('CaServiceError');
  });

  // -------------------------------------------------------------------------
  // Missing crt in response
  // -------------------------------------------------------------------------

  it('throws CaServiceError when response is missing the crt field', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(200, { ca: FAKE_CA_PEM });

    await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
      if (!(err instanceof CaServiceError)) return false;
      expect(err.message).toMatch(/missing the "crt" field/);
      return true;
    });
  });

  // -------------------------------------------------------------------------
  // M6: provisionerPassword must never appear in CaServiceError messages
  // -------------------------------------------------------------------------

  it('provisionerPassword does not appear in any CaServiceError message', async () => {
    // Temporarily set a recognizable password to test against
    const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
    process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345';

    // Generate a bad CSR to trigger an error path
    const caughtErrors: CaServiceError[] = [];
    try {
      await service.issueCert(makeReq({ csrPem: 'not-a-csr' }));
    } catch (err) {
      if (err instanceof CaServiceError) {
        caughtErrors.push(err);
      }
    }

    // Also try HTTP 401 path
    if (!realCsrPem) realCsrPem = await generateRealCsr();
    makeHttpsMock(401, { message: 'Unauthorized' });
    try {
      await service.issueCert(makeReq({ csrPem: realCsrPem }));
    } catch (err) {
      if (err instanceof CaServiceError) {
        caughtErrors.push(err);
      }
    }

    for (const err of caughtErrors) {
      expect(err.message).not.toContain('super-secret-password-12345');
      if (err.remediation) {
        expect(err.remediation).not.toContain('super-secret-password-12345');
      }
    }

    process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword;
  });

  // -------------------------------------------------------------------------
  // M7: HTTPS-only enforcement in constructor
  // -------------------------------------------------------------------------

  it('throws in constructor if STEP_CA_URL uses http://', () => {
    const originalUrl = process.env['STEP_CA_URL'];
    process.env['STEP_CA_URL'] = 'http://step-ca:9000';

    expect(() => new CaService()).toThrow(CaServiceError);

    process.env['STEP_CA_URL'] = originalUrl;
  });

  // -------------------------------------------------------------------------
  // TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum
  // -------------------------------------------------------------------------

  it('clamps ttlSeconds to 900 s regardless of input', async () => {
    if (!realCsrPem) realCsrPem = await generateRealCsr();

    let capturedBody: Record<string, unknown> | undefined;

    const mockReq = {
      write: vi.fn((data: string) => {
        capturedBody = JSON.parse(data) as Record<string, unknown>;
      }),
      end: vi.fn(),
      on: vi.fn(),
      setTimeout: vi.fn(),
    };

    (httpsModule.request as unknown as Mock).mockImplementation(
      (
        _options: unknown,
        callback: (res: {
          statusCode: number;
          on: (event: string, cb: (chunk?: Buffer) => void) => void;
        }) => void,
      ) => {
        const mockRes = {
          statusCode: 200,
          on: (event: string, cb: (chunk?: Buffer) => void) => {
            if (event === 'data') {
              cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem })));
            }
            if (event === 'end') {
              cb();
            }
          },
        };
        setImmediate(() => callback(mockRes));
        return mockReq;
      },
    );

    // Request 86400 s — should be clamped to 900
    await service.issueCert(makeReq({ ttlSeconds: 86400 }));

    expect(capturedBody).toBeDefined();
    const validity = capturedBody!['validity'] as Record<string, unknown>;
    expect(validity['duration']).toBe('900s');
  });
});
