/**
 * Federation M2 integration tests (FED-M2-09).
 *
 * Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
 *
 * Prerequisites:
 *   docker compose -f docker-compose.federated.yml --profile federated up -d
 *
 * Run DB-only tests (no Step-CA):
 *   FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
 *     src/__tests__/integration/federation-m2.integration.test.ts
 *
 * Run all tests including Step-CA-dependent ones:
 *   FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
 *   STEP_CA_URL=https://localhost:9000 \
 *   STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
 *   STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
 *   pnpm --filter @mosaicstack/gateway test \
 *     src/__tests__/integration/federation-m2.integration.test.ts
 *
 * Obtaining Step-CA credentials:
 *   # Extract provisioner key from running container:
 *   #   docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
 *   # Copy root cert from container:
 *   #   docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
 *   # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
 */

import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { GoneException } from '@nestjs/common';
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
import {
  createDb,
  type Db,
  type DbHandle,
  federationPeers,
  federationGrants,
  federationEnrollmentTokens,
  inArray,
  eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { seal } from '@mosaicstack/auth';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
import { CaService } from '../../federation/ca.service.js';
import { FederationScopeError } from '../../federation/scope-schema.js';

const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';

const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';

// ---------------------------------------------------------------------------
// Helpers for test data isolation
// ---------------------------------------------------------------------------

/** Unique run prefix to identify rows created by this test run. */
const RUN_ID = crypto.randomUUID();

/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
async function insertTestUser(db: Db, id: string): Promise<void> {
  await db
    .insert(schema.users)
    .values({
      id,
      name: `test-user-${id}`,
      email: `test-${id}@federation-test.invalid`,
      emailVerified: false,
    })
    .onConflictDoNothing();
}

/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
  await db
    .insert(federationPeers)
    .values({
      id,
      commonName: `test-peer-${RUN_ID}-${suffix}`,
      displayName: `Test Peer ${suffix}`,
      certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
      certSerial: `test-serial-${id}`,
      certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
      state: 'pending',
    })
    .onConflictDoNothing();
}

// ---------------------------------------------------------------------------
// DB-only test module (CaService mocked so env vars not required)
// ---------------------------------------------------------------------------

function buildDbModule(db: Db) {
  return Test.createTestingModule({
    providers: [
      { provide: DB, useValue: db },
      GrantsService,
      {
        provide: CaService,
        useValue: {
          issueCert: async () => {
            throw new Error('CaService.issueCert should not be called in DB-only tests');
          },
        },
      },
      EnrollmentService,
    ],
  }).compile();
}

// ---------------------------------------------------------------------------
// Test suite — DB-only (no Step-CA)
// ---------------------------------------------------------------------------

describe.skipIf(!run)('federation M2 — DB-only tests', () => {
  let handle: DbHandle;
  let db: Db;
  let grantsService: GrantsService;

  /** IDs created during this run — cleaned up in afterAll. */
  const createdGrantIds: string[] = [];
  const createdPeerIds: string[] = [];
  const createdUserIds: string[] = [];

  beforeAll(async () => {
    process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';

    handle = createDb(PG_URL);
    db = handle.db;

    const moduleRef = await buildDbModule(db);
    grantsService = moduleRef.get(GrantsService);
  });

  afterAll(async () => {
    // Clean up in FK-safe order: tokens → grants → peers → users
    if (db && createdGrantIds.length > 0) {
      await db
        .delete(federationEnrollmentTokens)
        .where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
      await db
        .delete(federationGrants)
        .where(inArray(federationGrants.id, createdGrantIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
    }
    if (db && createdPeerIds.length > 0) {
      await db
        .delete(federationPeers)
        .where(inArray(federationPeers.id, createdPeerIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
    }
    if (db && createdUserIds.length > 0) {
      await db
        .delete(schema.users)
        .where(inArray(schema.users.id, createdUserIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
    }
    if (handle)
      await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
  });

  // -------------------------------------------------------------------------
  // #1 — grant create writes a pending row
  // -------------------------------------------------------------------------
  it('#1 — createGrant writes a pending row to DB', async () => {
    const userId = crypto.randomUUID();
    const peerId = crypto.randomUUID();
    const validScope = {
      resources: ['tasks'],
      excluded_resources: [],
      max_rows_per_query: 100,
    };

    await insertTestUser(db, userId);
    await insertTestPeer(db, peerId, 'test1');
    createdUserIds.push(userId);
    createdPeerIds.push(peerId);

    const grant = await grantsService.createGrant({
      subjectUserId: userId,
      scope: validScope,
      peerId,
    });

    createdGrantIds.push(grant.id);

    // Verify the row exists in DB with correct shape
    const [row] = await db
      .select()
      .from(federationGrants)
      .where(eq(federationGrants.id, grant.id))
      .limit(1);

    expect(row).toBeDefined();
    expect(row?.status).toBe('pending');
    expect(row?.peerId).toBe(peerId);
    expect(row?.subjectUserId).toBe(userId);
    const storedScope = row?.scope as Record<string, unknown>;
    expect(storedScope['resources']).toEqual(['tasks']);
    expect(storedScope['max_rows_per_query']).toBe(100);
  }, 15_000);

  // -------------------------------------------------------------------------
  // #7 — scope with unknown resource type rejected
  // -------------------------------------------------------------------------
  it('#7 — createGrant rejects scope with unknown resource type', async () => {
    const userId = crypto.randomUUID();
    const peerId = crypto.randomUUID();
    const invalidScope = {
      resources: ['totally_unknown_resource'],
      excluded_resources: [],
      max_rows_per_query: 100,
    };

    await insertTestUser(db, userId);
    await insertTestPeer(db, peerId, 'test7');
    createdUserIds.push(userId);
    createdPeerIds.push(peerId);

    await expect(
      grantsService.createGrant({
        subjectUserId: userId,
        scope: invalidScope,
        peerId,
      }),
    ).rejects.toThrow(FederationScopeError);
  }, 15_000);

  // -------------------------------------------------------------------------
  // #8 — listGrants returns accurate status for grants in various states
  // -------------------------------------------------------------------------
  it('#8 — listGrants returns accurate status for grants in various states', async () => {
    const userId = crypto.randomUUID();
    const peerId = crypto.randomUUID();
    const validScope = {
      resources: ['notes'],
      excluded_resources: [],
      max_rows_per_query: 50,
    };

    await insertTestUser(db, userId);
    await insertTestPeer(db, peerId, 'test8');
    createdUserIds.push(userId);
    createdPeerIds.push(peerId);

    // Create two pending grants via GrantsService
    const grantA = await grantsService.createGrant({
      subjectUserId: userId,
      scope: validScope,
      peerId,
    });
    const grantB = await grantsService.createGrant({
      subjectUserId: userId,
      scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
      peerId,
    });
    createdGrantIds.push(grantA.id, grantB.id);

    // Insert a third grant directly in 'revoked' state to test status variety
    const [grantC] = await db
      .insert(federationGrants)
      .values({
        id: crypto.randomUUID(),
        subjectUserId: userId,
        peerId,
        scope: validScope,
        status: 'revoked',
        revokedAt: new Date(),
      })
      .returning();
    createdGrantIds.push(grantC!.id);

    // List all grants for this peer
    const allForPeer = await grantsService.listGrants({ peerId });

    const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
    const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
    expect(ourGrants).toHaveLength(3);

    const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
    const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
    expect(pendingGrants).toHaveLength(2);
    expect(revokedGrants).toHaveLength(1);

    // Status-filtered query
    const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
    const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
    expect(ourPending.every((g) => g.status === 'pending')).toBe(true);

    // Verify peer list from DB also shows the peer rows with correct state
    const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
    expect(peers).toHaveLength(1);
    expect(peers[0]?.state).toBe('pending');
  }, 15_000);

  // -------------------------------------------------------------------------
  // #5 — client_key_pem encrypted at rest
  // -------------------------------------------------------------------------
  it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
    const peerId = crypto.randomUUID();
    const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
    const sealed = seal(rawPem);

    await db.insert(federationPeers).values({
      id: peerId,
      commonName: `test-peer-${RUN_ID}-sealed`,
      displayName: 'Sealed Key Test Peer',
      certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
      certSerial: `test-serial-sealed-${peerId}`,
      certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
      state: 'pending',
      clientKeyPem: sealed,
    });
    createdPeerIds.push(peerId);

    const [row] = await db
      .select()
      .from(federationPeers)
      .where(eq(federationPeers.id, peerId))
      .limit(1);

    expect(row).toBeDefined();
    // The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
    expect(row?.clientKeyPem).toBeDefined();
    expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
    // The sealed value should be non-trivial (at least 20 chars)
    expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
  }, 15_000);
});

// ---------------------------------------------------------------------------
// Test suite — Step-CA gated
// ---------------------------------------------------------------------------

describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
  let handle: DbHandle;
  let db: Db;
  let grantsService: GrantsService;
  let enrollmentService: EnrollmentService;

  const createdGrantIds: string[] = [];
  const createdPeerIds: string[] = [];
  const createdUserIds: string[] = [];

  beforeAll(async () => {
    handle = createDb(PG_URL);
    db = handle.db;

    // Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
    // STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
    const moduleRef = await Test.createTestingModule({
      providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
    }).compile();

    grantsService = moduleRef.get(GrantsService);
    enrollmentService = moduleRef.get(EnrollmentService);
  });

  afterAll(async () => {
    if (db && createdGrantIds.length > 0) {
      await db
        .delete(federationEnrollmentTokens)
        .where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
      await db
        .delete(federationGrants)
        .where(inArray(federationGrants.id, createdGrantIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
    }
    if (db && createdPeerIds.length > 0) {
      await db
        .delete(federationPeers)
        .where(inArray(federationPeers.id, createdPeerIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
    }
    if (db && createdUserIds.length > 0) {
      await db
        .delete(schema.users)
        .where(inArray(schema.users.id, createdUserIds))
        .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
    }
    if (handle)
      await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
  });

  /** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
  async function generateCsrPem(cn: string): Promise<string> {
    const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
    const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
    const csr = await Pkcs10CertificateRequestGenerator.create({
      name: `CN=${cn}`,
      keys: keyPair,
      signingAlgorithm: alg,
    });
    return csr.toString('pem');
  }

  // -------------------------------------------------------------------------
  // #2 — enrollment signs CSR and returns cert
  // -------------------------------------------------------------------------
  it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
    const userId = crypto.randomUUID();
    const peerId = crypto.randomUUID();
    const validScope = {
      resources: ['tasks'],
      excluded_resources: [],
      max_rows_per_query: 100,
    };

    await insertTestUser(db, userId);
    await insertTestPeer(db, peerId, 'ca-test2');
    createdUserIds.push(userId);
    createdPeerIds.push(peerId);

    const grant = await grantsService.createGrant({
      subjectUserId: userId,
      scope: validScope,
      peerId,
    });
    createdGrantIds.push(grant.id);

    const { token } = await enrollmentService.createToken({
      grantId: grant.id,
      peerId,
      ttlSeconds: 900,
    });

    const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
    const result = await enrollmentService.redeem(token, csrPem);

    expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
    expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');

    // Verify the issued cert parses cleanly
    const cert = new PeculiarX509(result.certPem);
    expect(cert.serialNumber).toBeTruthy();
  }, 30_000);

  // -------------------------------------------------------------------------
  // #3 — token single-use; second attempt returns GoneException
  // -------------------------------------------------------------------------
  it('#3 — second redeem of the same token throws GoneException', async () => {
    const userId = crypto.randomUUID();
    const peerId = crypto.randomUUID();
    const validScope = {
      resources: ['notes'],
      excluded_resources: [],
      max_rows_per_query: 50,
    };

    await insertTestUser(db, userId);
    await insertTestPeer(db, peerId, 'ca-test3');
    createdUserIds.push(userId);
    createdPeerIds.push(peerId);

    const grant = await grantsService.createGrant({
      subjectUserId: userId,
      scope: validScope,
      peerId,
    });
    createdGrantIds.push(grant.id);

    const { token } = await enrollmentService.createToken({
      grantId: grant.id,
      peerId,
      ttlSeconds: 900,
    });

    const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);

    // First redeem must succeed
    const result = await enrollmentService.redeem(token, csrPem);
    expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');

    // Second redeem with the same token must be rejected
    await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
  }, 30_000);
});
