/**
 * MCP integration tests (Issue #239 / Phase 7).
 *
 * Drives the real OAuth Express router + registry + token-manager + discovery
 * against a real HTTP mock server (`mock-mcp-server.ts`). Complements the
 * existing unit tests, which mock at finer granularity.
 *
 * Out of scope:
 *   - End-to-end SDK Streamable HTTP transport (uses SSE; tested via fake Client
 *     in `aggregator.test.ts` / `tool-executor.test.ts`)
 *   - Refresh-on-401 retry at aggregator/client level (separate issue)
 */
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express, { type Express } from 'express';
import { type Server } from 'node:http';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry, type McpRegistry } from './registry.js';
import { createTokenManager } from './token-manager.js';
import { createMcpOauthRouter } from './oauth-routes.js';
import { fetchDiscovery, refreshAccessToken } from './discovery.js';
import { startMockMcpServer, type MockMcpServer } from './testing/mock-mcp-server.js';

const validKey = 'a'.repeat(64);

interface TestRig {
  db: Database.Database;
  mock: MockMcpServer;
  registry: McpRegistry;
  app: Express;
  appServer: Server;
  appOrigin: string;
  resumedCalls: Array<[string, string]>;
}

async function bootRig(tools: { name: string; description?: string }[] = []): Promise<TestRig> {
  process.env.MCP_ENCRYPTION_KEY = validKey;
  const db = new Database(':memory:');
  db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
  db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
  db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
  runMigrations(db);
  db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');

  const mock = await startMockMcpServer({ tools });
  const registry = createRegistry(db);

  const tm = createTokenManager(db, {
    // Mirror production wiring: look up server endpoint + credentials, then refreshAccessToken().
    // insecureLocalTestMode allows loopback for the mock server.
    doRefresh: async (serverId, refreshToken) => {
      const server = registry.getDecrypted(serverId);
      if (!server || !server.tokenEndpoint) throw new Error(`no server/tokenEndpoint for ${serverId}`);
      return refreshAccessToken(
        {
          tokenEndpoint: server.tokenEndpoint,
          clientId: server.oauthClientId,
          clientSecret: server.oauthClientSecret,
          refreshToken,
        },
        { insecureLocalTestMode: true },
      );
    },
  });

  const resumedCalls: Array<[string, string]> = [];

  const app = express();
  let appOrigin = '';
  app.use(
    '/auth/mcp',
    createMcpOauthRouter({
      db,
      registry,
      tokenManager: tm,
      pendingTtlMinutes: 10,
      getCallbackBaseUrl: () => appOrigin,
      getAuthenticatedUserId: () => 'u1',
      resumeWaitingJobs: (uid, sid) => {
        resumedCalls.push([uid, sid]);
      },
      insecureLocalTestMode: true,
    }),
  );
  const appServer = app.listen(0, '127.0.0.1');
  await new Promise<void>((resolve) => appServer.once('listening', resolve));
  appOrigin = `http://127.0.0.1:${(appServer.address() as { port: number }).port}`;

  return { db, mock, registry, app, appServer, appOrigin, resumedCalls };
}

async function tearDownRig(rig: TestRig): Promise<void> {
  await new Promise<void>((r) => rig.appServer.close(() => r()));
  await rig.mock.close();
  rig.db.close();
  delete process.env.MCP_ENCRYPTION_KEY;
}

describe('MCP integration: OAuth dance against real mock server', () => {
  let rig: TestRig;
  beforeEach(async () => { rig = await bootRig(); });
  afterEach(async () => { await tearDownRig(rig); });

  it('discovery → registry → start → callback → token saved + encrypted at rest', async () => {
    // 1. Discovery against the mock
    const disco = await fetchDiscovery(rig.mock.origin + '/mcp', { insecureLocalTestMode: true });
    expect(disco).toMatchObject({
      issuer: rig.mock.origin,
      authorizationEndpoint: `${rig.mock.origin}/authorize`,
      tokenEndpoint: `${rig.mock.origin}/token`,
    });
    expect(disco.fingerprint).toMatch(/^[0-9a-f]{64}$/);

    // 2. Register the server with discovery metadata
    rig.registry.upsert({
      id: 'mocksrv',
      name: 'Mock',
      url: `${rig.mock.origin}/mcp`,
      authKind: 'oauth',
      ownerId: null,
      oauthClientId: 'test-client',
      oauthClientSecret: 'test-secret',
      oauthScopes: 'read',
    });
    rig.registry.setDiscovery('mocksrv', {
      issuer: disco.issuer,
      authorizationEndpoint: disco.authorizationEndpoint,
      tokenEndpoint: disco.tokenEndpoint,
      fingerprint: disco.fingerprint,
    });

    // 3. Start the OAuth dance — orchestrator redirects to mock /authorize
    const startRes = await fetch(`${rig.appOrigin}/auth/mcp/mocksrv/start`, { redirect: 'manual' });
    expect(startRes.status).toBe(302);
    const authorizeUrl = startRes.headers.get('location')!;
    expect(authorizeUrl).toContain(`${rig.mock.origin}/authorize`);
    expect(authorizeUrl).toMatch(/code_challenge_method=S256/);

    // 4. Follow the redirect to mock /authorize — it issues a code and redirects back
    const authorizeRes = await fetch(authorizeUrl, { redirect: 'manual' });
    expect(authorizeRes.status).toBe(302);
    const callbackUrl = authorizeRes.headers.get('location')!;
    expect(callbackUrl).toContain(`${rig.appOrigin}/auth/mcp/mocksrv/callback`);

    // 5. Hit the callback — orchestrator exchanges code with mock /token, persists tokens
    const callbackRes = await fetch(callbackUrl, { redirect: 'manual' });
    expect(callbackRes.status).toBe(302);

    const tokenRow = rig.db
      .prepare('SELECT access_token_enc, refresh_token_enc, expires_at FROM user_mcp_tokens WHERE user_id=? AND server_id=?')
      .get('u1', 'mocksrv') as { access_token_enc: Buffer; refresh_token_enc: Buffer; expires_at: string };
    expect(tokenRow).toBeTruthy();
    expect(tokenRow.access_token_enc).toBeInstanceOf(Buffer);
    expect(tokenRow.refresh_token_enc).toBeInstanceOf(Buffer);
    expect(new Date(tokenRow.expires_at).getTime()).toBeGreaterThan(Date.now());

    // 6. Plaintext token bytes never appear in the DB column (encrypted at rest)
    expect(tokenRow.access_token_enc.toString('utf-8')).not.toMatch(/^at-/);
    expect(tokenRow.refresh_token_enc.toString('utf-8')).not.toMatch(/^rt-/);

    // 7. Resume callback fired
    expect(rig.resumedCalls).toEqual([['u1', 'mocksrv']]);
  });

  it('refresh roundtrip: expired token triggers /token POST with grant_type=refresh_token', async () => {
    // Same dance to acquire initial tokens
    const disco = await fetchDiscovery(rig.mock.origin + '/mcp', { insecureLocalTestMode: true });
    rig.registry.upsert({
      id: 'mocksrv',
      name: 'Mock',
      url: `${rig.mock.origin}/mcp`,
      authKind: 'oauth',
      ownerId: null,
      oauthClientId: 'test-client',
      oauthClientSecret: 'test-secret',
      oauthScopes: null,
    });
    rig.registry.setDiscovery('mocksrv', {
      issuer: disco.issuer,
      authorizationEndpoint: disco.authorizationEndpoint,
      tokenEndpoint: disco.tokenEndpoint,
      fingerprint: disco.fingerprint,
    });
    const startRes = await fetch(`${rig.appOrigin}/auth/mcp/mocksrv/start`, { redirect: 'manual' });
    const authorizeRes = await fetch(startRes.headers.get('location')!, { redirect: 'manual' });
    await fetch(authorizeRes.headers.get('location')!, { redirect: 'manual' });

    // Force the saved token to be expired
    rig.db
      .prepare("UPDATE user_mcp_tokens SET expires_at = datetime('now', '-1 hour') WHERE user_id=? AND server_id=?")
      .run('u1', 'mocksrv');

    // Recreate the token manager bound to this registry so getValidToken triggers refresh
    const tm = createTokenManager(rig.db, {
      doRefresh: async (serverId, refreshToken) => {
        const server = rig.registry.getDecrypted(serverId);
        return refreshAccessToken(
          {
            tokenEndpoint: server!.tokenEndpoint!,
            clientId: server!.oauthClientId,
            clientSecret: server!.oauthClientSecret,
            refreshToken,
          },
          { insecureLocalTestMode: true },
        );
      },
    });
    const refreshed = await tm.getValidToken('u1', 'mocksrv');
    expect(refreshed).toMatch(/^at-/);  // mock issues a fresh `at-<rnd>` token

    // Refresh roundtrip is also visible on the mock side (the token row is rotated)
    expect(rig.mock.issuedTokens.has(refreshed)).toBe(true);
  });

  it('callback rejected when state was not issued by start', async () => {
    rig.registry.upsert({
      id: 'mocksrv',
      name: 'Mock',
      url: `${rig.mock.origin}/mcp`,
      authKind: 'oauth',
      ownerId: null,
      oauthClientId: 'test-client',
      oauthClientSecret: 'test-secret',
      oauthScopes: 'read',
    });

    const res = await fetch(
      `${rig.appOrigin}/auth/mcp/mocksrv/callback?code=anything&state=NOT_ISSUED`,
      { redirect: 'manual' },
    );
    expect(res.status).toBe(400);
  });
});

describe('MCP integration: api_key bearer flow', () => {
  let rig: TestRig;
  beforeEach(async () => {
    rig = await bootRig([{ name: 'echo', description: 'echo args back' }]);
    rig.mock.options.staticToken = 'sk-test-1234';
  });
  afterEach(async () => { await tearDownRig(rig); });

  it('registry persists encrypted static token; tokenManager returns it for any user', async () => {
    rig.registry.upsert({
      id: 'apikey-srv',
      name: 'ApiKey Mock',
      url: `${rig.mock.origin}/mcp`,
      authKind: 'api_key',
      ownerId: null,
      staticToken: 'sk-test-1234',
    });

    // Static token stored encrypted
    const row = rig.db.prepare('SELECT static_token_enc FROM mcp_servers WHERE id=?').get('apikey-srv') as { static_token_enc: Buffer };
    expect(row.static_token_enc).toBeInstanceOf(Buffer);
    expect(row.static_token_enc.toString('utf-8')).not.toContain('sk-test-1234');

    const tm = createTokenManager(rig.db, {
      doRefresh: async () => { throw new Error('api_key servers should not refresh'); },
    });
    expect(tm.hasToken('u1', 'apikey-srv')).toBe(true);
    expect(tm.hasToken('any-other-user', 'apikey-srv')).toBe(true);
    const bearer = await tm.getValidToken('u1', 'apikey-srv');
    expect(bearer).toBe('sk-test-1234');
  });

  it('static token authenticates against the mock /mcp endpoint (tools/list + tools/call)', async () => {
    // Hit /mcp directly with the bearer to validate the HTTP contract.
    // (Real SDK transport uses SSE — out of scope; tested separately via fake Client.)
    const listRes = await fetch(`${rig.mock.origin}/mcp`, {
      method: 'POST',
      headers: { authorization: 'Bearer sk-test-1234', 'content-type': 'application/json' },
      body: JSON.stringify({ method: 'tools/list' }),
    });
    expect(listRes.ok).toBe(true);
    const listJson = await listRes.json() as { result: { tools: Array<{ name: string }> } };
    expect(listJson.result.tools.map((t) => t.name)).toEqual(['echo']);

    const callRes = await fetch(`${rig.mock.origin}/mcp`, {
      method: 'POST',
      headers: { authorization: 'Bearer sk-test-1234', 'content-type': 'application/json' },
      body: JSON.stringify({ method: 'tools/call', params: { name: 'echo', arguments: { x: 1 } } }),
    });
    expect(callRes.ok).toBe(true);
    const callJson = await callRes.json() as { result: { content: Array<{ type: string; text: string }> } };
    expect(callJson.result.content[0].text).toBe('mock-called echo');

    // Wrong bearer is rejected with 401
    const unauthRes = await fetch(`${rig.mock.origin}/mcp`, {
      method: 'POST',
      headers: { authorization: 'Bearer wrong', 'content-type': 'application/json' },
      body: JSON.stringify({ method: 'tools/list' }),
    });
    expect(unauthRes.status).toBe(401);
  });
});

describe('MCP integration: refresh failure deletes token', () => {
  let rig: TestRig;
  beforeEach(async () => { rig = await bootRig(); });
  afterEach(async () => { await tearDownRig(rig); });

  it('expired token + invalid_grant on refresh → token row is deleted', async () => {
    rig.registry.upsert({
      id: 'mocksrv',
      name: 'Mock',
      url: `${rig.mock.origin}/mcp`,
      authKind: 'oauth',
      ownerId: null,
      oauthClientId: 'test-client',
      oauthClientSecret: 'test-secret',
      oauthScopes: null,
    });
    rig.registry.setDiscovery('mocksrv', {
      issuer: rig.mock.origin,
      authorizationEndpoint: `${rig.mock.origin}/authorize`,
      tokenEndpoint: `${rig.mock.origin}/token`,
      fingerprint: 'fp',
    });

    const tm = createTokenManager(rig.db, {
      doRefresh: async (serverId, refreshToken) => {
        const server = rig.registry.getDecrypted(serverId);
        return refreshAccessToken(
          {
            tokenEndpoint: server!.tokenEndpoint!,
            clientId: server!.oauthClientId,
            clientSecret: server!.oauthClientSecret,
            refreshToken,
          },
          { insecureLocalTestMode: true },
        );
      },
    });

    // Save a token row with an expired timestamp and a bogus refresh token.
    tm.saveTokens({
      userId: 'u1',
      serverId: 'mocksrv',
      accessToken: 'old-at',
      refreshToken: 'never-issued-rt',
      expiresAt: new Date(Date.now() - 60_000).toISOString(),
      scope: null,
    });
    expect(tm.hasToken('u1', 'mocksrv')).toBe(true);

    await expect(tm.getValidToken('u1', 'mocksrv')).rejects.toThrow();
    expect(tm.hasToken('u1', 'mocksrv')).toBe(false);
  });
});
