/**
 * Federation M3 single-gateway integration tests (FED-M3-10).
 *
 * Covers MILESTONES.md M3 acceptance:
 *  - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
 *  - #7: max_rows_per_query caps list results.
 *
 * Strategy:
 *  - Real PostgreSQL via @mosaicstack/db.
 *  - Mocked TLS context/Fastify request shim for FederationAuthGuard.
 *  - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
 *
 * Run:
 *   FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
 *     src/__tests__/integration/federation-m3-list.integration.test.ts
 */

import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
  and,
  createDb,
  eq,
  federationGrants,
  federationPeers,
  inArray,
  missionTasks,
  missions,
  projects,
  tasks,
  teamMembers,
  teams,
  type Db,
  type DbHandle,
  users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
  makeMosaicIssuedCert,
  makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';

const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();

interface TestIds {
  readonly subjectUserId: string;
  readonly otherUserId: string;
  readonly peerId: string;
  readonly revokedPeerId: string;
  readonly activeGrantId: string;
  readonly revokedGrantId: string;
  readonly subjectProjectId: string;
  readonly subjectMissionId: string;
  readonly otherProjectId: string;
  readonly teamId: string;
  readonly unauthorizedTeamId: string;
  readonly teamProjectId: string;
  readonly taskIds: readonly string[];
  readonly excludedTaskIds: readonly string[];
  readonly subjectNoteId: string;
  readonly otherUserNoteId: string;
}

function pemToDer(pem: string): Buffer {
  return Buffer.from(
    pem
      .replace(/-----BEGIN CERTIFICATE-----/, '')
      .replace(/-----END CERTIFICATE-----/, '')
      .replace(/\s+/g, ''),
    'base64',
  );
}

function makeFederationRequest(certPem: string): FastifyRequest {
  return {
    raw: {
      socket: {
        getPeerCertificate: () => ({
          raw: pemToDer(certPem),
          serialNumber: CERT_SERIAL_HEX,
        }),
      },
    },
  } as unknown as FastifyRequest;
}

function makeGuardContext(request: FastifyRequest): {
  readonly context: ExecutionContext;
  readonly sent: { statusCode?: number; payload?: unknown };
} {
  const sent: { statusCode?: number; payload?: unknown } = {};
  const reply = {
    status: (statusCode: number) => {
      sent.statusCode = statusCode;
      return {
        header: () => ({
          send: (payload: unknown) => {
            sent.payload = payload;
          },
        }),
      };
    },
  } as unknown as FastifyReply;

  const context = {
    switchToHttp: () => ({
      getRequest: () => request,
      getResponse: () => reply,
    }),
  } as unknown as ExecutionContext;

  return { context, sent };
}

async function insertUser(db: Db, id: string, label: string): Promise<void> {
  await db.insert(users).values({
    id,
    name: `${RUN_ID}-${label}`,
    email: `${RUN_ID}-${label}@federation-test.invalid`,
    emailVerified: false,
  });
}

async function seedFixtures(db: Db): Promise<TestIds> {
  const subjectUserId = `${RUN_ID}-subject`;
  const otherUserId = `${RUN_ID}-other`;
  const peerId = crypto.randomUUID();
  const revokedPeerId = crypto.randomUUID();
  const activeGrantId = crypto.randomUUID();
  const revokedGrantId = crypto.randomUUID();
  const subjectProjectId = crypto.randomUUID();
  const subjectMissionId = crypto.randomUUID();
  const otherProjectId = crypto.randomUUID();
  const teamId = crypto.randomUUID();
  const unauthorizedTeamId = crypto.randomUUID();
  const teamProjectId = crypto.randomUUID();
  const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
  const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
  const subjectNoteId = crypto.randomUUID();
  const otherUserNoteId = crypto.randomUUID();

  await insertUser(db, subjectUserId, 'subject');
  await insertUser(db, otherUserId, 'other');

  await db.insert(teams).values([
    {
      id: teamId,
      name: `${RUN_ID} allowed team`,
      slug: `${RUN_ID}-allowed-team`,
      ownerId: subjectUserId,
      managerId: subjectUserId,
    },
    {
      id: unauthorizedTeamId,
      name: `${RUN_ID} unauthorized team`,
      slug: `${RUN_ID}-unauthorized-team`,
      ownerId: otherUserId,
      managerId: otherUserId,
    },
  ]);

  await db.insert(teamMembers).values([
    { teamId, userId: subjectUserId, role: 'member' },
    { teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
  ]);

  await db.insert(projects).values([
    {
      id: subjectProjectId,
      name: `${RUN_ID} subject personal project`,
      ownerType: 'user',
      ownerId: subjectUserId,
    },
    {
      id: otherProjectId,
      name: `${RUN_ID} other personal project`,
      ownerType: 'user',
      ownerId: otherUserId,
    },
    {
      id: teamProjectId,
      name: `${RUN_ID} unauthorized team project`,
      ownerType: 'team',
      teamId: unauthorizedTeamId,
    },
  ]);

  await db.insert(missions).values({
    id: subjectMissionId,
    name: `${RUN_ID} subject mission`,
    projectId: subjectProjectId,
    userId: subjectUserId,
  });

  await db.insert(tasks).values([
    {
      id: taskIds[0],
      title: `${RUN_ID} visible task 1`,
      missionId: subjectMissionId,
      createdAt: new Date('2026-06-25T03:00:00.000Z'),
      updatedAt: new Date('2026-06-25T03:00:00.000Z'),
    },
    {
      id: taskIds[1],
      title: `${RUN_ID} visible task 2`,
      projectId: subjectProjectId,
      createdAt: new Date('2026-06-25T02:00:00.000Z'),
      updatedAt: new Date('2026-06-25T02:00:00.000Z'),
    },
    {
      id: taskIds[2],
      title: `${RUN_ID} visible task 3`,
      projectId: subjectProjectId,
      createdAt: new Date('2026-06-25T01:00:00.000Z'),
      updatedAt: new Date('2026-06-25T01:00:00.000Z'),
    },
    {
      id: excludedTaskIds[0],
      title: `${RUN_ID} other user task`,
      projectId: otherProjectId,
      createdAt: new Date('2026-06-25T04:00:00.000Z'),
      updatedAt: new Date('2026-06-25T04:00:00.000Z'),
    },
    {
      id: excludedTaskIds[1],
      title: `${RUN_ID} unauthorized team task`,
      projectId: teamProjectId,
      createdAt: new Date('2026-06-25T05:00:00.000Z'),
      updatedAt: new Date('2026-06-25T05:00:00.000Z'),
    },
  ]);

  await db.insert(missionTasks).values([
    {
      id: subjectNoteId,
      missionId: subjectMissionId,
      userId: subjectUserId,
      notes: `${RUN_ID} subject visible note`,
      createdAt: new Date('2026-06-25T03:30:00.000Z'),
      updatedAt: new Date('2026-06-25T03:30:00.000Z'),
    },
    {
      id: otherUserNoteId,
      missionId: subjectMissionId,
      userId: otherUserId,
      notes: `${RUN_ID} other user note on subject mission`,
      createdAt: new Date('2026-06-25T04:30:00.000Z'),
      updatedAt: new Date('2026-06-25T04:30:00.000Z'),
    },
  ]);

  await db.insert(federationPeers).values([
    {
      id: peerId,
      commonName: `${RUN_ID}-active-peer`,
      displayName: `${RUN_ID} Active Peer`,
      certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
      certSerial: CERT_SERIAL_HEX,
      certNotAfter: new Date(Date.now() + 86_400_000),
      state: 'active',
    },
    {
      id: revokedPeerId,
      commonName: `${RUN_ID}-revoked-peer`,
      displayName: `${RUN_ID} Revoked Peer`,
      certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
      certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
      certNotAfter: new Date(Date.now() + 86_400_000),
      state: 'active',
    },
  ]);

  await db.insert(federationGrants).values([
    {
      id: activeGrantId,
      peerId,
      subjectUserId,
      status: 'active',
      scope: {
        resources: ['tasks', 'notes'],
        excluded_resources: [],
        filters: {
          tasks: { include_personal: true, include_teams: [] },
          notes: { include_personal: true, include_teams: [] },
        },
        max_rows_per_query: 2,
      },
    },
    {
      id: revokedGrantId,
      peerId,
      subjectUserId,
      status: 'revoked',
      revokedAt: new Date(),
      revokedReason: `${RUN_ID} revoked grant fixture`,
      scope: {
        resources: ['tasks'],
        excluded_resources: [],
        max_rows_per_query: 2,
      },
    },
  ]);

  return {
    subjectUserId,
    otherUserId,
    peerId,
    revokedPeerId,
    activeGrantId,
    revokedGrantId,
    subjectProjectId,
    subjectMissionId,
    otherProjectId,
    teamId,
    unauthorizedTeamId,
    teamProjectId,
    taskIds,
    excludedTaskIds,
    subjectNoteId,
    otherUserNoteId,
  };
}

async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
  if (!ids) {
    return;
  }

  await db
    .delete(missionTasks)
    .where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
    .catch(() => {});
  await db
    .delete(tasks)
    .where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
    .catch(() => {});
  await db
    .delete(missions)
    .where(eq(missions.id, ids.subjectMissionId))
    .catch(() => {});
  await db
    .delete(projects)
    .where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
    .catch(() => {});
  await db
    .delete(teamMembers)
    .where(
      and(
        eq(teamMembers.userId, ids.subjectUserId),
        inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
      ),
    )
    .catch(() => {});
  await db
    .delete(teams)
    .where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
    .catch(() => {});
  await db
    .delete(federationGrants)
    .where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
    .catch(() => {});
  await db
    .delete(federationPeers)
    .where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
    .catch(() => {});
  await db
    .delete(users)
    .where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
    .catch(() => {});
}

describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
  let handle: DbHandle;
  let db: Db;
  let moduleRef: TestingModule;
  let guard: FederationAuthGuard;
  let listController: ListController;
  let ids: TestIds | undefined;

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

    moduleRef = await Test.createTestingModule({
      controllers: [ListController],
      providers: [
        { provide: DB, useValue: db },
        GrantsService,
        FederationAuthGuard,
        FederationScopeService,
        FederationListQueryService,
      ],
    }).compile();

    guard = moduleRef.get(FederationAuthGuard);
    listController = moduleRef.get(ListController);
  }, 30_000);

  afterAll(async () => {
    await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
    await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
    await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
  });

  it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
    const malformedOidCert = await makeSelfSignedCert();
    const request = makeFederationRequest(malformedOidCert);
    const { context, sent } = makeGuardContext(request);

    await expect(guard.canActivate(context)).resolves.toBe(false);
    expect(sent.statusCode).toBe(401);
    expect(sent.payload).toMatchObject({
      error: {
        code: 'unauthorized',
        message: expect.stringContaining('missing required OID'),
      },
    });
    expect(request.federationContext).toBeUndefined();
  });

  it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
    expect(ids).toBeDefined();
    const revokedCert = await makeMosaicIssuedCert({
      grantId: ids!.revokedGrantId,
      subjectUserId: ids!.subjectUserId,
    });
    const request = makeFederationRequest(revokedCert);
    const { context, sent } = makeGuardContext(request);

    await expect(guard.canActivate(context)).resolves.toBe(false);
    expect(sent.statusCode).toBe(403);
    expect(sent.payload).toMatchObject({
      error: {
        code: 'forbidden',
        message: 'Federation access denied',
      },
    });
    expect(request.federationContext).toBeUndefined();
  });

  it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
    expect(ids).toBeDefined();
    const activeCert = await makeMosaicIssuedCert({
      grantId: ids!.activeGrantId,
      subjectUserId: ids!.subjectUserId,
    });
    const request = makeFederationRequest(activeCert);
    const { context } = makeGuardContext(request);

    await expect(guard.canActivate(context)).resolves.toBe(true);

    const response = await listController.list('tasks', request, { limit: 100 });
    const returnedIds = response.items.map((item) => item['id']);

    expect(response.items).toHaveLength(2);
    expect(response._truncated).toBe(true);
    expect(response.nextCursor).toEqual(expect.any(String));
    expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
    expect(returnedIds).not.toContain(ids!.taskIds[2]);
    for (const excludedId of ids!.excludedTaskIds) {
      expect(returnedIds).not.toContain(excludedId);
    }
    expect(response.items.every((item) => item._source === 'local')).toBe(true);
  });

  it('excludes another user mission task notes on the same authorized mission', async () => {
    expect(ids).toBeDefined();
    const activeCert = await makeMosaicIssuedCert({
      grantId: ids!.activeGrantId,
      subjectUserId: ids!.subjectUserId,
    });
    const request = makeFederationRequest(activeCert);
    const { context } = makeGuardContext(request);

    await expect(guard.canActivate(context)).resolves.toBe(true);

    const response = await listController.list('notes', request, { limit: 10 });
    const returnedIds = response.items.map((item) => item['id']);

    expect(returnedIds).toEqual([ids!.subjectNoteId]);
    expect(returnedIds).not.toContain(ids!.otherUserNoteId);
    expect(response.items.every((item) => item._source === 'local')).toBe(true);
  });

  it('fails closed for unsupported list resources', async () => {
    expect(ids).toBeDefined();
    const activeCert = await makeMosaicIssuedCert({
      grantId: ids!.activeGrantId,
      subjectUserId: ids!.subjectUserId,
    });
    const request = makeFederationRequest(activeCert);
    const { context } = makeGuardContext(request);

    await expect(guard.canActivate(context)).resolves.toBe(true);

    await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
      response: {
        error: {
          code: 'scope_violation',
          message: 'Requested federation resource is not supported',
        },
      },
      status: 403,
    });
  });
});
