/**
 * Unit tests for src/middleware/opa.ts
 *
 * All tests run in fallback mode (scopes.json).
 * `fs.existsSync` is mocked to return false for the Wasm bundle path so
 * the Wasm loader is bypassed and `loadScopesFallback()` is always called.
 *
 * POLICY_DIR is set to the real `policies/` directory in the project root so
 * tests use the production `data/scopes.json` without duplicating it.
 */

import path from 'path';
import { Request, Response, NextFunction } from 'express';
import { RequestHandler } from 'express';
import { AuthorizationError } from '../../../src/utils/errors';
import { ITokenPayload } from '../../../src/types/index';

// ─── Point POLICY_DIR at the real policies directory ─────────────────────────

const PROJECT_ROOT = path.resolve(__dirname, '../../..');
const POLICIES_DIR = path.join(PROJECT_ROOT, 'policies');

process.env['POLICY_DIR'] = POLICIES_DIR;

// ─── Mock fs.existsSync so Wasm bundle is never found ────────────────────────
// We do this BEFORE importing the module under test so the module-level
// WASM_PATH check in `loadWasmPolicy()` always returns false.

jest.mock('fs', () => {
  const actual = jest.requireActual<typeof import('fs')>('fs');
  return {
    ...actual,
    existsSync: jest.fn((filePath: unknown) => {
      // Deny Wasm bundle; allow all other paths (including scopes.json)
      if (typeof filePath === 'string' && filePath.endsWith('.wasm')) {
        return false;
      }
      return actual.existsSync(filePath as string);
    }),
  };
});

// ─── Import the module under test AFTER mocks are in place ───────────────────

import { createOpaMiddleware, reloadOpaPolicy } from '../../../src/middleware/opa';

// ─── Helpers ──────────────────────────────────────────────────────────────────

function makeUser(scope: string): ITokenPayload {
  return {
    sub: 'agent-abc-123',
    client_id: 'agent-abc-123',
    scope,
    jti: 'jti-001',
    iat: 1000,
    exp: 9999999999,
  };
}

/**
 * Builds a minimal mock Express Request for OPA middleware testing.
 * The middleware uses `req.baseUrl + req.path` for the full path.
 */
function makeReq(
  method: string,
  baseUrl: string,
  reqPath: string,
  user?: ITokenPayload,
): Partial<Request> {
  return {
    method,
    baseUrl,
    path: reqPath,
    user,
  };
}

// ─── Test suite ───────────────────────────────────────────────────────────────

describe('createOpaMiddleware (fallback mode)', () => {
  let middleware: RequestHandler;
  let next: jest.MockedFunction<NextFunction>;

  beforeAll(async () => {
    // Create middleware once; all tests share the same loaded scopes.json
    middleware = await createOpaMiddleware();
  });

  beforeEach(() => {
    next = jest.fn();
  });

  // ── Unauthenticated request ───────────────────────────────────────────────

  it('should call next(AuthorizationError) with "not authenticated" when req.user is absent', () => {
    const req = makeReq('GET', '/api/v1', '/agents', undefined) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledTimes(1);
    const err = (next as jest.Mock).mock.calls[0][0] as AuthorizationError;
    expect(err).toBeInstanceOf(AuthorizationError);
    expect(err.message).toMatch(/not authenticated/i);
  });

  // ── agents:read endpoints ──────────────────────────────────────────────────

  it('should allow GET /api/v1/agents with agents:read scope', () => {
    const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny GET /api/v1/agents with agents:write scope only', () => {
    const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:write')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should deny GET /api/v1/agents when scope list is empty', () => {
    const req = makeReq('GET', '/api/v1', '/agents', makeUser('')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should allow GET /api/v1/agents/:id with agents:read scope (UUID path)', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000',
      makeUser('agents:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny GET /api/v1/agents/:id with wrong scope', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000',
      makeUser('audit:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  // ── agents:write endpoints ─────────────────────────────────────────────────

  it('should allow POST /api/v1/agents with agents:write scope', () => {
    const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:write')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny POST /api/v1/agents with agents:read scope only', () => {
    const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:read')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should allow PATCH /api/v1/agents/:id with agents:write scope', () => {
    const req = makeReq(
      'PATCH',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should allow DELETE /api/v1/agents/:id with agents:write scope', () => {
    const req = makeReq(
      'DELETE',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  // ── credentials sub-resource endpoints ────────────────────────────────────

  it('should allow GET /api/v1/agents/:id/credentials with agents:read scope', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000/credentials',
      makeUser('agents:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should allow POST /api/v1/agents/:id/credentials with agents:write scope', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000/credentials',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should allow POST /api/v1/agents/:id/credentials/:credId/rotate with agents:write scope', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001/rotate',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny POST /api/v1/agents/:id/credentials/:credId/rotate with agents:read scope', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001/rotate',
      makeUser('agents:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should allow DELETE /api/v1/agents/:id/credentials/:credId with agents:write scope', () => {
    const req = makeReq(
      'DELETE',
      '/api/v1',
      '/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  // ── tokens:read endpoints ──────────────────────────────────────────────────

  it('should allow POST /api/v1/token/introspect with tokens:read scope', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/token/introspect',
      makeUser('tokens:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny POST /api/v1/token/introspect with agents:read scope only', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/token/introspect',
      makeUser('agents:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should allow POST /api/v1/token/revoke with tokens:read scope', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/token/revoke',
      makeUser('tokens:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny POST /api/v1/token/revoke without tokens:read scope', () => {
    const req = makeReq(
      'POST',
      '/api/v1',
      '/token/revoke',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  // ── audit:read endpoints ───────────────────────────────────────────────────

  it('should allow GET /api/v1/audit with audit:read scope', () => {
    const req = makeReq('GET', '/api/v1', '/audit', makeUser('audit:read')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny GET /api/v1/audit with agents:read scope only', () => {
    const req = makeReq('GET', '/api/v1', '/audit', makeUser('agents:read')) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should allow GET /api/v1/audit/:id with audit:read scope (UUID path)', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/audit/550e8400-e29b-41d4-a716-446655440000',
      makeUser('audit:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should deny GET /api/v1/audit/:id without audit:read scope', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/audit/550e8400-e29b-41d4-a716-446655440000',
      makeUser('tokens:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  // ── Path normalisation ────────────────────────────────────────────────────

  it('should normalise UUID agent path correctly (longest segment first)', () => {
    // Full rotate path with real UUIDs — must hit the :credId/rotate rule
    const req = makeReq(
      'POST',
      '/api/v1',
      '/agents/a1b2c3d4-e5f6-4000-8000-ef1234567890/credentials/b2c3d4e5-f6a7-4000-8000-fa2345678901/rotate',
      makeUser('agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should normalise path with non-UUID segment identifiers', () => {
    // Non-UUID IDs are still matched by the regex (any non-slash characters)
    const req = makeReq(
      'GET',
      '/api/v1',
      '/agents/my-agent-slug',
      makeUser('agents:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  // ── Unknown / unmapped paths ──────────────────────────────────────────────

  it('should deny (fail-closed) when path has no matching entry in scopes.json', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/unknown/resource',
      makeUser('agents:read agents:write tokens:read audit:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  it('should deny (fail-closed) for a valid path with wrong HTTP method', () => {
    // PUT is not in scopes.json for any endpoint
    const req = makeReq(
      'PUT',
      '/api/v1',
      '/agents',
      makeUser('agents:read agents:write'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });

  // ── Multi-scope token ─────────────────────────────────────────────────────

  it('should allow access when token has multiple scopes including the required one', () => {
    const req = makeReq(
      'GET',
      '/api/v1',
      '/audit',
      makeUser('agents:read tokens:read audit:read'),
    ) as Request;
    middleware(req, {} as Response, next);

    expect(next).toHaveBeenCalledWith(/* no error */);
  });
});

// ─── reloadOpaPolicy ──────────────────────────────────────────────────────────

describe('reloadOpaPolicy()', () => {
  it('should reload without error and continue to enforce policy correctly', async () => {
    await expect(reloadOpaPolicy()).resolves.toBeUndefined();

    // After reload, fallback mode should still work — create a fresh middleware
    const mw = await createOpaMiddleware();
    const next = jest.fn() as jest.MockedFunction<NextFunction>;

    const req = {
      method: 'GET',
      baseUrl: '/api/v1',
      path: '/agents',
      user: {
        sub: 'agent-xyz',
        client_id: 'agent-xyz',
        scope: 'agents:read',
        jti: 'jti-reload',
        iat: 1000,
        exp: 9999999999,
      },
    } as unknown as Request;

    mw(req, {} as Response, next);
    expect(next).toHaveBeenCalledWith(/* no error */);
  });

  it('should still deny access after reload when scope is insufficient', async () => {
    await reloadOpaPolicy();
    const mw = await createOpaMiddleware();
    const next = jest.fn() as jest.MockedFunction<NextFunction>;

    const req = {
      method: 'POST',
      baseUrl: '/api/v1',
      path: '/token/introspect',
      user: {
        sub: 'agent-xyz',
        client_id: 'agent-xyz',
        scope: 'agents:read',
        jti: 'jti-reload-deny',
        iat: 1000,
        exp: 9999999999,
      },
    } as unknown as Request;

    mw(req, {} as Response, next);
    expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
  });
});
