/**
 * Unit tests for FederationClientService (FED-M3-08).
 *
 * HTTP mocking strategy:
 *   undici MockAgent is used to intercept outbound HTTP requests.  The service
 *   uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the
 *   global dispatcher and all requests flow through it.
 *
 *   Because the service builds one `undici.Agent` per peer and passes it as
 *   the dispatcher on every fetch call, we cannot intercept at the Agent level
 *   in unit tests without significant refactoring.  Instead, we set the global
 *   dispatcher to a MockAgent and override the service's `doRequest` indirection
 *   by spying on the internal fetch call.
 *
 *   For the cert/key wiring, we use the real `sealClientKey` function from
 *   peer-key.util.ts with a test secret — no stubs.
 *
 * Sealed-key setup:
 *   Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with
 *   BETTER_AUTH_SECRET set to a deterministic test value so that
 *   `unsealClientKey` in the service recovers the original PEM.
 */

import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import type { Dispatcher } from 'undici';
import { writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Db } from '@mosaicstack/db';
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
import { sealClientKey } from '../../peer-key.util.js';

// ---------------------------------------------------------------------------
// Test constants
// ---------------------------------------------------------------------------

const TEST_SECRET = 'test-secret-for-federation-client-spec-only';
const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const ENDPOINT = 'https://peer.example.com';

// Minimal valid RSA/EC private key PEM — does NOT need to be a real key for
// unit tests because we only verify it round-trips through seal/unseal, not
// that it actually negotiates TLS (MockAgent handles that).
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests
-----END PRIVATE KEY-----`;

// Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction)
const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBdummyCertForFederationClientTests==
-----END CERTIFICATE-----`;

const TEST_CERT_SERIAL = 'ABCDEF1234567890';

// ---------------------------------------------------------------------------
// Sealed key (computed once in beforeAll)
// ---------------------------------------------------------------------------

let SEALED_KEY: string;

// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
MIIBdummyCAforFederationClientSpecOnly==
-----END CERTIFICATE-----
`;

// ---------------------------------------------------------------------------
// Peer row factory
// ---------------------------------------------------------------------------

function makePeerRow(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    id: PEER_ID,
    commonName: 'peer-example-com',
    displayName: 'Test Peer',
    certPem: TEST_CERT_PEM,
    certSerial: TEST_CERT_SERIAL,
    certNotAfter: new Date('2030-01-01T00:00:00Z'),
    clientKeyPem: SEALED_KEY,
    state: 'active' as const,
    endpointUrl: ENDPOINT,
    lastSeenAt: null,
    createdAt: new Date('2026-01-01T00:00:00Z'),
    revokedAt: null,
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Mock DB builder
// ---------------------------------------------------------------------------

function makeDb(selectRows: unknown[] = [makePeerRow()]): Db {
  const limitSelect = vi.fn().mockResolvedValue(selectRows);
  const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
  const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
  const selectMock = vi.fn().mockReturnValue({ from: fromSelect });

  return {
    select: selectMock,
    insert: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    transaction: vi.fn(),
  } as unknown as Db;
}

// ---------------------------------------------------------------------------
// Helpers for MockAgent HTTP interception
// ---------------------------------------------------------------------------

/**
 * Create a MockAgent + MockPool for the peer endpoint, set it as the global
 * dispatcher, and return both for per-test configuration.
 */
function makeMockAgent() {
  const mockAgent = new MockAgent({ connections: 1 });
  mockAgent.disableNetConnect();
  setGlobalDispatcher(mockAgent);
  const pool = mockAgent.get(ENDPOINT);
  return { mockAgent, pool };
}

/**
 * Build a FederationClientService with a mock DB and a spy on the internal
 * fetch so we can intercept at the HTTP layer via MockAgent.
 *
 * The service calls `fetch(url, { dispatcher: agent })` where `agent` is the
 * mTLS undici.Agent built from the peer's cert+key.  To make MockAgent work,
 * we need the fetch dispatcher to be the MockAgent, not the per-peer Agent.
 *
 * Strategy: we replace the private `resolveEntry` result's `agent` field with
 * the MockAgent's pool, so fetch uses our interceptor.  We do this by spying
 * on `resolveEntry` and returning a controlled entry.
 */
function makeService(db: Db, mockPool: Dispatcher): FederationClientService {
  const svc = new FederationClientService(db);

  // Override resolveEntry to inject MockAgent pool as the dispatcher
  vi.spyOn(
    svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
    'resolveEntry',
  ).mockImplementation(async (_peerId: string) => {
    // Still call DB (via the real logic) to exercise peer validation,
    // but return mock pool as the agent.
    // For simplicity in unit tests, directly return a controlled entry.
    return {
      agent: mockPool,
      endpointUrl: ENDPOINT,
      certPem: TEST_CERT_PEM,
      certSerial: TEST_CERT_SERIAL,
    };
  });

  return svc;
}

// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------

let originalDispatcher: Dispatcher;

beforeAll(() => {
  // Seal the test key once — requires BETTER_AUTH_SECRET
  const saved = process.env['BETTER_AUTH_SECRET'];
  process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
  try {
    SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM);
  } finally {
    if (saved === undefined) {
      delete process.env['BETTER_AUTH_SECRET'];
    } else {
      process.env['BETTER_AUTH_SECRET'] = saved;
    }
  }
  writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
});

afterAll(() => {
  try {
    unlinkSync(STUB_CA_PEM_PATH);
  } catch {
    // best-effort cleanup
  }
});

beforeEach(() => {
  originalDispatcher = getGlobalDispatcher();
  process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
  process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
});

afterEach(() => {
  setGlobalDispatcher(originalDispatcher);
  vi.restoreAllMocks();
  delete process.env['BETTER_AUTH_SECRET'];
  delete process.env['STEP_CA_ROOT_CERT_PATH'];
});

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

/** Successful list response body */
const LIST_BODY = {
  items: [{ id: '1', title: 'Task One' }],
  nextCursor: undefined,
  _partial: false,
};

/** Successful get response body */
const GET_BODY = {
  item: { id: '1', title: 'Task One' },
  _partial: false,
};

/** Successful capabilities response body */
const CAP_BODY = {
  resources: ['tasks'],
  excluded_resources: [],
  max_rows_per_query: 100,
  supported_verbs: ['list', 'get', 'capabilities'] as const,
};

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

describe('FederationClientService', () => {
  // ─── Successful verb calls ─────────────────────────────────────────────────

  describe('list()', () => {
    it('returns parsed typed response on success', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool
        .intercept({
          path: '/api/federation/v1/list/tasks',
          method: 'POST',
        })
        .reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } });

      const result = await svc.list(PEER_ID, 'tasks', {});

      expect(result.items).toHaveLength(1);
      expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' });

      await mockAgent.close();
    });
  });

  describe('get()', () => {
    it('returns parsed typed response on success', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool
        .intercept({
          path: '/api/federation/v1/get/tasks/1',
          method: 'POST',
        })
        .reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } });

      const result = await svc.get(PEER_ID, 'tasks', '1', {});

      expect(result.item).toMatchObject({ id: '1', title: 'Task One' });

      await mockAgent.close();
    });
  });

  describe('capabilities()', () => {
    it('returns parsed capabilities response on success', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool
        .intercept({
          path: '/api/federation/v1/capabilities',
          method: 'GET',
        })
        .reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } });

      const result = await svc.capabilities(PEER_ID);

      expect(result.resources).toContain('tasks');
      expect(result.max_rows_per_query).toBe(100);

      await mockAgent.close();
    });
  });

  // ─── HTTP error surfaces ──────────────────────────────────────────────────

  describe('non-2xx responses', () => {
    it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply(
        403,
        { error: { code: 'forbidden', message: 'Access denied' } },
        {
          headers: { 'content-type': 'application/json' },
        },
      );

      await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({
        status: 403,
        code: 'FORBIDDEN',
        peerId: PEER_ID,
      });

      await mockAgent.close();
    });

    it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply(
        404,
        { error: { code: 'not_found', message: 'Not found' } },
        {
          headers: { 'content-type': 'application/json' },
        },
      );

      await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({
        status: 404,
        code: 'HTTP_404',
        peerId: PEER_ID,
      });

      await mockAgent.close();
    });
  });

  // ─── Network error ─────────────────────────────────────────────────────────

  describe('network errors', () => {
    it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool
        .intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
        .replyWithError(new Error('ECONNREFUSED'));

      await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
        code: 'NETWORK',
        peerId: PEER_ID,
      });

      await mockAgent.close();
    });
  });

  // ─── Invalid response body ─────────────────────────────────────────────────

  describe('invalid response body', () => {
    it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      // capabilities returns wrong shape (missing required fields)
      pool
        .intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
        .reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } });

      await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
        code: 'INVALID_RESPONSE',
        peerId: PEER_ID,
      });

      await mockAgent.close();
    });
  });

  // ─── Peer DB validation ────────────────────────────────────────────────────

  describe('peer validation (without resolveEntry spy)', () => {
    /**
     * These tests exercise the real `resolveEntry` path — no spy on resolveEntry.
     */

    it('throws PEER_NOT_FOUND when peer is not in DB', async () => {
      // DB returns empty array (peer not found)
      const db = makeDb([]);
      const svc = new FederationClientService(db);

      await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
        code: 'PEER_NOT_FOUND',
        peerId: PEER_ID,
      });
    });

    it('throws PEER_INACTIVE when peer state is not "active"', async () => {
      const db = makeDb([makePeerRow({ state: 'suspended' })]);
      const svc = new FederationClientService(db);

      await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
        code: 'PEER_INACTIVE',
        peerId: PEER_ID,
      });
    });
  });

  // ─── Cache behaviour ───────────────────────────────────────────────────────

  describe('cache behaviour', () => {
    it('hits cache on second call — only one DB lookup happens', async () => {
      // Verify cache by calling the private resolveEntry directly twice and
      // asserting the DB was queried only once. This avoids the HTTP layer,
      // which would require either a real network or per-peer Agent rewiring
      // that the cache invariant doesn't depend on.
      const db = makeDb();
      const selectSpy = vi.spyOn(db, 'select');
      const svc = new FederationClientService(db);
      const resolveEntry = (
        svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
      ).resolveEntry.bind(svc);

      const first = await resolveEntry(PEER_ID);
      const second = await resolveEntry(PEER_ID);

      expect(first).toBe(second);
      expect(selectSpy).toHaveBeenCalledTimes(1);
    });

    it('serializes concurrent resolveEntry calls — only one DB lookup', async () => {
      const db = makeDb();
      const selectSpy = vi.spyOn(db, 'select');
      const svc = new FederationClientService(db);
      const resolveEntry = (
        svc as unknown as {
          resolveEntry: (peerId: string) => Promise<unknown>;
        }
      ).resolveEntry.bind(svc);

      const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]);
      expect(a).toBe(b);
      expect(selectSpy).toHaveBeenCalledTimes(1);
    });

    it('flushPeer destroys the evicted Agent so old TLS connections close', async () => {
      const db = makeDb();
      const svc = new FederationClientService(db);
      const resolveEntry = (
        svc as unknown as {
          resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise<void> } }>;
        }
      ).resolveEntry.bind(svc);

      const entry = await resolveEntry(PEER_ID);
      const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue();

      svc.flushPeer(PEER_ID);
      expect(destroySpy).toHaveBeenCalledTimes(1);
    });

    it('flushPeer() invalidates cache — next call re-reads DB', async () => {
      const db = makeDb();
      const { mockAgent, pool } = makeMockAgent();
      const svc = makeService(db, pool);

      pool
        .intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
        .reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
        .times(2);

      // First call — populates cache (via mock resolveEntry)
      await svc.capabilities(PEER_ID);

      // Flush the cache
      svc.flushPeer(PEER_ID);

      // The spy on resolveEntry is still active — check it's called again after flush
      const resolveEntrySpy = vi.spyOn(
        svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
        'resolveEntry',
      );

      // Second call after flush — should call resolveEntry again
      await svc.capabilities(PEER_ID);

      // resolveEntry should have been called once after we started spying (post-flush)
      expect(resolveEntrySpy).toHaveBeenCalledTimes(1);

      await mockAgent.close();
    });
  });

  // ─── loadStepCaRoot env-var guard ─────────────────────────────────────────

  describe('loadStepCaRoot() env-var guard', () => {
    it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => {
      delete process.env['STEP_CA_ROOT_CERT_PATH'];
      const db = makeDb();
      const svc = new FederationClientService(db);
      const resolveEntry = (
        svc as unknown as {
          resolveEntry: (peerId: string) => Promise<unknown>;
        }
      ).resolveEntry.bind(svc);

      await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({
        code: 'PEER_MISCONFIGURED',
      });
    });
  });

  // ─── FederationClientError class ──────────────────────────────────────────

  describe('FederationClientError', () => {
    it('is instanceof Error and FederationClientError', () => {
      const err = new FederationClientError({
        code: 'PEER_NOT_FOUND',
        message: 'test',
        peerId: PEER_ID,
      });
      expect(err).toBeInstanceOf(Error);
      expect(err).toBeInstanceOf(FederationClientError);
      expect(err.name).toBe('FederationClientError');
    });

    it('carries status, code, and peerId', () => {
      const err = new FederationClientError({
        status: 403,
        code: 'FORBIDDEN',
        message: 'forbidden',
        peerId: PEER_ID,
      });
      expect(err.status).toBe(403);
      expect(err.code).toBe('FORBIDDEN');
      expect(err.peerId).toBe(PEER_ID);
    });
  });
});
