import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { REQUIRE_MFA_METADATA, RequireMfaGuard } from './require-mfa.guard';

interface SessionStub {
  user?: {
    oid: string;
    tid: string;
    username: string;
    displayName: string;
    amr: readonly string[];
    roles: readonly string[];
  };
  mfaVerifiedAt?: number;
}

function makeRequest(opts: {
  session?: SessionStub;
  method?: string;
  originalUrl?: string;
}): Request {
  return {
    session: opts.session,
    method: opts.method ?? 'GET',
    originalUrl: opts.originalUrl ?? '/api/admin/me',
  } as unknown as Request;
}

function makeContext(req: Request): ExecutionContext {
  return {
    switchToHttp: () => ({ getRequest: <T>() => req as T }),
    getHandler: () => () => undefined,
    getClass: () => class {},
  } as unknown as ExecutionContext;
}

function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'mfaRequired'>> {
  return { mfaRequired: jest.fn().mockResolvedValue(undefined) };
}

function makeReflectorStub(metadata?: { freshness?: number }): Reflector {
  return {
    getAllAndOverride: jest.fn().mockReturnValue(metadata),
  } as unknown as Reflector;
}

const USER = {
  oid: 'user-oid',
  tid: 't',
  username: 'jane@example',
  displayName: 'Jane',
  amr: ['pwd', 'mfa'],
  roles: ['admin'],
  groups: [],
};

describe('RequireMfaGuard', () => {
  const originalFreshness = process.env['MFA_FRESHNESS_SECONDS'];

  afterEach(() => {
    if (originalFreshness === undefined) {
      delete process.env['MFA_FRESHNESS_SECONDS'];
    } else {
      process.env['MFA_FRESHNESS_SECONDS'] = originalFreshness;
    }
    jest.useRealTimers();
  });

  it('throws 401 unauthenticated when there is no session', async () => {
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: undefined }));
    await expect(guard.canActivate(ctx)).rejects.toMatchObject({
      response: { code: 'unauthenticated' },
    });
    expect(audit.mfaRequired).not.toHaveBeenCalled();
  });

  it('throws 401 unauthenticated when the session has no user', async () => {
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: {} }));
    await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
    expect(audit.mfaRequired).not.toHaveBeenCalled();
  });

  it('throws 401 mfa_required + audits when amr carries no MFA-class token', async () => {
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(
      makeRequest({
        session: { user: { ...USER, amr: ['pwd'] }, mfaVerifiedAt: Date.now() },
        method: 'POST',
        originalUrl: '/api/admin/audit/query',
      }),
    );
    await expect(guard.canActivate(ctx)).rejects.toMatchObject({
      response: { code: 'mfa_required' },
    });
    expect(audit.mfaRequired).toHaveBeenCalledWith({
      actor: { oid: 'user-oid' },
      attemptedRoute: 'POST /api/admin/audit/query',
      reason: 'no-mfa-in-amr',
      freshnessSeconds: 600,
    });
  });

  it('throws 401 mfa_required + audits when mfaVerifiedAt is missing from the session', async () => {
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: { user: USER /* no mfaVerifiedAt */ } }));
    await expect(guard.canActivate(ctx)).rejects.toMatchObject({
      response: { code: 'mfa_required' },
    });
    expect(audit.mfaRequired).toHaveBeenCalledWith({
      actor: { oid: 'user-oid' },
      attemptedRoute: 'GET /api/admin/me',
      reason: 'no-mfa-verified-at',
      freshnessSeconds: 600,
    });
  });

  it('throws 401 mfa_required + audits with mfaAgeMs when assertion is stale', async () => {
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const stale = now - 700_000; // 700 s old, default freshness is 600 s
    const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: stale } }));
    await expect(guard.canActivate(ctx)).rejects.toMatchObject({
      response: { code: 'mfa_required' },
    });
    expect(audit.mfaRequired).toHaveBeenCalledWith({
      actor: { oid: 'user-oid' },
      attemptedRoute: 'GET /api/admin/me',
      reason: 'mfa-stale',
      freshnessSeconds: 600,
      mfaAgeMs: 700_000,
    });
  });

  it('returns true when MFA assertion is within the freshness window', async () => {
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: now - 60_000 } }));
    await expect(guard.canActivate(ctx)).resolves.toBe(true);
    expect(audit.mfaRequired).not.toHaveBeenCalled();
  });

  it('returns true at the exact freshness boundary (age == freshness)', async () => {
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: now - 600_000 } }));
    await expect(guard.canActivate(ctx)).resolves.toBe(true);
  });

  it('honours a per-route freshness override (tighter than env default)', async () => {
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    const audit = makeAuditStub();
    // Default env (600 s) would pass; route asks for 60 s, so a
    // 5-min-old assertion must be rejected.
    const guard = new RequireMfaGuard(
      makeReflectorStub({ freshness: 60 }),
      audit as unknown as AuditWriter,
    );
    const ctx = makeContext(
      makeRequest({ session: { user: USER, mfaVerifiedAt: now - 5 * 60_000 } }),
    );
    await expect(guard.canActivate(ctx)).rejects.toMatchObject({
      response: { code: 'mfa_required' },
    });
    expect(audit.mfaRequired).toHaveBeenCalledWith(
      expect.objectContaining({ reason: 'mfa-stale', freshnessSeconds: 60 }),
    );
  });

  it('honours MFA_FRESHNESS_SECONDS when set and no per-route override', async () => {
    process.env['MFA_FRESHNESS_SECONDS'] = '120';
    const now = 10_000_000_000;
    jest.useFakeTimers();
    jest.setSystemTime(now);
    const audit = makeAuditStub();
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: now - 180_000 } }));
    await expect(guard.canActivate(ctx)).rejects.toMatchObject({
      response: { code: 'mfa_required' },
    });
    expect(audit.mfaRequired).toHaveBeenCalledWith(
      expect.objectContaining({ freshnessSeconds: 120 }),
    );
  });

  it('propagates audit-write failures (no audit ⇒ no action, per ADR-0013)', async () => {
    const audit = {
      mfaRequired: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
    };
    const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: { user: { ...USER, amr: ['pwd'] } } }));
    await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
  });

  it('reads the per-route options via the Reflector under the documented metadata key', async () => {
    const audit = makeAuditStub();
    const reflector = makeReflectorStub({ freshness: 60 });
    const guard = new RequireMfaGuard(reflector, audit as unknown as AuditWriter);
    const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: Date.now() } }));
    await guard.canActivate(ctx);
    expect(reflector.getAllAndOverride).toHaveBeenCalledWith(
      REQUIRE_MFA_METADATA,
      expect.any(Array),
    );
  });
});
