/**
 * Unit tests for the HTTP-mode token-auth preHandler.
 *
 * Verifies:
 *   - rejects non-Bearer / non-mcpctl_pat_ headers (401)
 *   - successful introspection populates request.mcpToken
 *   - positive results are cached up to the positive TTL
 *   - **revoked tokens surface as 401 within the negative-TTL window** ≤ 5s
 *   - wrong-project path → 403
 */
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { createTokenAuthMiddleware } from '../../src/http/token-auth.js';

interface IntrospectResponse {
  ok: boolean;
  tokenName?: string;
  tokenSha?: string;
  projectName?: string;
  revoked?: boolean;
  expired?: boolean;
}

function makeFetch(response: IntrospectResponse, status = 200) {
  const fn = vi.fn(async () => ({
    ok: status >= 200 && status < 300,
    json: async () => response,
  }) as unknown as Response);
  return fn;
}

async function setupApp(deps: Parameters<typeof createTokenAuthMiddleware>[0]) {
  const app = Fastify({ logger: false });
  const middleware = createTokenAuthMiddleware(deps);
  app.addHook('preHandler', middleware);
  app.get('/projects/:projectName/mcp', async (request) => ({
    ok: true,
    mcpToken: request.mcpToken,
  }));
  await app.ready();
  return app;
}

describe('token-auth preHandler', () => {
  it('rejects requests with no Authorization header (401)', async () => {
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: makeFetch({ ok: true }) });
    const res = await app.inject({ method: 'GET', url: '/projects/foo/mcp' });
    expect(res.statusCode).toBe(401);
    await app.close();
  });

  it('rejects bearers that are not mcpctl_pat_ tokens (401)', async () => {
    const fetchFn = makeFetch({ ok: true });
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn });
    const res = await app.inject({
      method: 'GET',
      url: '/projects/foo/mcp',
      headers: { authorization: 'Bearer some-session-token' },
    });
    expect(res.statusCode).toBe(401);
    expect(fetchFn).not.toHaveBeenCalled();
    await app.close();
  });

  it('passes valid tokens and populates request.mcpToken', async () => {
    const fetchFn = makeFetch({ ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' });
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn });
    const res = await app.inject({
      method: 'GET',
      url: '/projects/foo/mcp',
      headers: { authorization: 'Bearer mcpctl_pat_valid' },
    });
    expect(res.statusCode).toBe(200);
    const body = res.json<{ mcpToken: { tokenName: string; projectName: string } }>();
    expect(body.mcpToken.tokenName).toBe('demo');
    expect(body.mcpToken.projectName).toBe('foo');
    await app.close();
  });

  it('rejects with 403 when the token is bound to a different project', async () => {
    const fetchFn = makeFetch({ ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' });
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn });
    const res = await app.inject({
      method: 'GET',
      url: '/projects/other/mcp',
      headers: { authorization: 'Bearer mcpctl_pat_valid' },
    });
    expect(res.statusCode).toBe(403);
    await app.close();
  });

  it('caches positive introspections (does not re-hit mcpd within TTL)', async () => {
    const fetchFn = makeFetch({ ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' });
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn, positiveTtlMs: 30_000 });
    const h = { authorization: 'Bearer mcpctl_pat_valid' };
    await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h });
    await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h });
    await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h });
    expect(fetchFn).toHaveBeenCalledTimes(1);
    await app.close();
  });

  it('surfaces revocation as 401 within the 5s negative cache (lag ≤ 5s)', async () => {
    // Simulate a revocation: first call returns ok:true, then flip to ok:false+revoked.
    let revoked = false;
    const fetchFn = vi.fn(async () => ({
      ok: !revoked,
      json: async () => revoked
        ? { ok: false, revoked: true, tokenName: 'demo', tokenSha: 'abc' }
        : { ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' },
    }) as unknown as Response);

    // Short positive TTL so revocation is seen immediately once the mcpd response flips.
    const app = await setupApp({
      mcpdUrl: 'http://mcpd',
      fetch: fetchFn,
      positiveTtlMs: 10,
      negativeTtlMs: 5_000,
    });
    const h = { authorization: 'Bearer mcpctl_pat_valid' };

    const first = await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h });
    expect(first.statusCode).toBe(200);

    // Revoke out-of-band.
    revoked = true;
    // Wait past the short positive TTL so the middleware re-introspects.
    await new Promise((r) => setTimeout(r, 15));

    const second = await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h });
    expect(second.statusCode).toBe(401);
    expect(second.json<{ error: string }>().error).toContain('revoked');
    await app.close();
  });

  it('returns 401 when mcpd introspect returns ok:false (unknown / invalid token)', async () => {
    const fetchFn = vi.fn(async () => ({
      ok: false,
      json: async () => ({ ok: false, error: 'Invalid token' }),
    }) as unknown as Response);
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn });
    const res = await app.inject({
      method: 'GET',
      url: '/projects/foo/mcp',
      headers: { authorization: 'Bearer mcpctl_pat_unknown' },
    });
    expect(res.statusCode).toBe(401);
    await app.close();
  });

  it('returns 401 (not a crash) when mcpd is unreachable', async () => {
    const fetchFn = vi.fn(async () => { throw new Error('ECONNREFUSED'); });
    const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn });
    const res = await app.inject({
      method: 'GET',
      url: '/projects/foo/mcp',
      headers: { authorization: 'Bearer mcpctl_pat_valid' },
    });
    expect(res.statusCode).toBe(401);
    await app.close();
  });
});
