/**
 * OPA (Open Policy Agent) authorization middleware for SentryAgent.ai AgentIdP.
 *
 * Primary path  — Wasm bundle: When `${POLICY_DIR}/authz.wasm` exists, the policy is
 *   evaluated using `@open-policy-agent/opa-wasm`. The bundle is loaded once at startup
 *   and can be hot-reloaded via `reloadOpaPolicy()`.
 *
 * Fallback path — scopes.json: When no Wasm bundle is present (dev/test), the policy
 *   is evaluated entirely in TypeScript by reading `${POLICY_DIR}/data/scopes.json` and
 *   applying the same normalisation + scope-intersection logic as `policies/authz.rego`.
 */

import fs from 'fs';
import path from 'path';
import { RequestHandler } from 'express';
import { loadPolicy, LoadedPolicy } from '@open-policy-agent/opa-wasm';
import { AuthorizationError } from '../utils/errors.js';

// ────────────────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────────────────

/** Input shape sent to the OPA policy for every authorization check. */
interface OpaInput {
  /** HTTP method in uppercase — e.g. "GET". */
  method: string;
  /** Full request path — e.g. "/api/v1/agents/abc-123". */
  path: string;
  /** Scopes extracted from the caller's JWT — e.g. ["agents:read"]. */
  scopes: string[];
}

/** Expected shape of the OPA Wasm result set entry. */
interface OpaResultEntry {
  result?: {
    allow?: boolean;
  };
}

/**
 * Shape of `policies/data/scopes.json`.
 * Keys are `"METHOD:/normalised/path"`, values are arrays of required scope strings.
 */
interface ScopesJson {
  endpoint_permissions: Record<string, string[]>;
}

// ────────────────────────────────────────────────────────────────────────────
// Module-level singletons (replaced on hot-reload)
// ────────────────────────────────────────────────────────────────────────────

/**
 * Resolved base directory for policy files.
 * Defaults to `<cwd>/policies`; override via `POLICY_DIR` environment variable.
 */
const POLICY_DIR: string =
  process.env['POLICY_DIR'] ?? path.resolve(process.cwd(), 'policies');

const WASM_PATH = path.join(POLICY_DIR, 'authz.wasm');
const SCOPES_PATH = path.join(POLICY_DIR, 'data', 'scopes.json');

/** Active Wasm policy instance — null when running in fallback (scopes.json) mode. */
let wasmPolicy: LoadedPolicy | null = null;

/** Fallback scope map — null when running in Wasm mode. */
let scopesMap: Record<string, string[]> | null = null;

// ────────────────────────────────────────────────────────────────────────────
// Path normalisation (mirrors `normalise_path` in authz.rego)
// ────────────────────────────────────────────────────────────────────────────

/**
 * Normalises a concrete request path to the pattern key used in `scopes.json`.
 * The priority ordering mirrors the longest-match ordering in `authz.rego`.
 *
 * @param requestPath - Raw request path from `req.path`.
 * @returns Normalised pattern string, or the original path if no pattern matches.
 */
function normalisePath(requestPath: string): string {
  // /api/v1/agents/:id/credentials/:credId/rotate  (longest — checked first)
  if (/^\/api\/v1\/agents\/[^/]+\/credentials\/[^/]+\/rotate$/.test(requestPath)) {
    return '/api/v1/agents/:id/credentials/:credId/rotate';
  }
  // /api/v1/agents/:id/credentials/:credId
  if (/^\/api\/v1\/agents\/[^/]+\/credentials\/[^/]+$/.test(requestPath)) {
    return '/api/v1/agents/:id/credentials/:credId';
  }
  // /api/v1/agents/:id/credentials
  if (/^\/api\/v1\/agents\/[^/]+\/credentials$/.test(requestPath)) {
    return '/api/v1/agents/:id/credentials';
  }
  // /api/v1/agents/:id
  if (/^\/api\/v1\/agents\/[^/]+$/.test(requestPath)) {
    return '/api/v1/agents/:id';
  }
  // Static paths — returned as-is
  if (
    requestPath === '/api/v1/agents' ||
    requestPath === '/api/v1/token/introspect' ||
    requestPath === '/api/v1/token/revoke' ||
    requestPath === '/api/v1/audit'
  ) {
    return requestPath;
  }
  // /api/v1/audit/:id
  if (/^\/api\/v1\/audit\/[^/]+$/.test(requestPath)) {
    return '/api/v1/audit/:id';
  }
  // /api/v1/federation/partners/:id
  if (/^\/api\/v1\/federation\/partners\/[^/]+$/.test(requestPath)) {
    return '/api/v1/federation/partners/:id';
  }
  // /api/v1/federation/partners
  if (requestPath === '/api/v1/federation/partners') {
    return '/api/v1/federation/partners';
  }
  // /api/v1/federation/trust (static)
  if (requestPath === '/api/v1/federation/trust') {
    return '/api/v1/federation/trust';
  }
  // Unknown path — return as-is; the policy will produce no match → deny
  return requestPath;
}

// ────────────────────────────────────────────────────────────────────────────
// Policy loading helpers
// ────────────────────────────────────────────────────────────────────────────

/**
 * Attempts to load the Wasm bundle from disk.
 * Returns `true` if successful, `false` if the file does not exist.
 *
 * @returns Whether the Wasm bundle was loaded.
 */
async function loadWasmPolicy(): Promise<boolean> {
  if (!fs.existsSync(WASM_PATH)) {
    return false;
  }

  const wasmBuffer = fs.readFileSync(WASM_PATH);
  const loaded = await loadPolicy(wasmBuffer);

  // Load the scopes data so the Wasm policy has access to endpoint_permissions
  if (fs.existsSync(SCOPES_PATH)) {
    const raw = fs.readFileSync(SCOPES_PATH, 'utf-8');
    const parsed = JSON.parse(raw) as ScopesJson;
    loaded.setData(parsed);
  }

  wasmPolicy = loaded;
  scopesMap = null;
  return true;
}

/**
 * Loads the fallback `scopes.json` into memory.
 * Called when no Wasm bundle is present.
 */
function loadScopesFallback(): void {
  const raw = fs.readFileSync(SCOPES_PATH, 'utf-8');
  const parsed = JSON.parse(raw) as ScopesJson;
  scopesMap = parsed.endpoint_permissions;
  wasmPolicy = null;
}

// ────────────────────────────────────────────────────────────────────────────
// Authorization evaluation
// ────────────────────────────────────────────────────────────────────────────

/**
 * Evaluates the OPA policy against the given input.
 *
 * Uses the Wasm bundle when available; falls back to TypeScript scope-map logic.
 *
 * @param input - The authorization input (method, path, scopes).
 * @returns `true` if the policy allows the request; `false` otherwise.
 */
function evaluate(input: OpaInput): boolean {
  if (wasmPolicy !== null) {
    // Wasm path: evaluate and extract `allow` from the result set
    const resultSet = wasmPolicy.evaluate(input) as OpaResultEntry[];
    if (!Array.isArray(resultSet) || resultSet.length === 0) {
      return false;
    }
    return resultSet[0]?.result?.allow === true;
  }

  if (scopesMap !== null) {
    // Fallback path: replicate authz.rego logic in TypeScript
    const normPath = normalisePath(input.path);
    const lookupKey = `${input.method}:${normPath}`;
    const required = scopesMap[lookupKey];

    // If no entry exists for this endpoint the policy denies
    if (!required) {
      return false;
    }

    return required.every((s) => input.scopes.includes(s));
  }

  // Neither policy loaded — deny by default (fail-closed)
  return false;
}

// ────────────────────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────────────────────

/**
 * Creates the OPA authorization middleware.
 *
 * Call once at application startup. The returned `RequestHandler` can be wired
 * into any Express router after the authentication middleware.
 *
 * Startup order:
 *  1. Try to load `${POLICY_DIR}/authz.wasm` (primary).
 *  2. If the Wasm bundle is absent, load `${POLICY_DIR}/data/scopes.json` (fallback).
 *
 * @returns Promise resolving to an Express `RequestHandler` for OPA authorization.
 * @throws Error if neither the Wasm bundle nor `scopes.json` can be loaded.
 */
export async function createOpaMiddleware(): Promise<RequestHandler> {
  const wasmLoaded = await loadWasmPolicy();

  if (wasmLoaded) {
    console.log('[AgentIdP] OPA policy engine: Wasm mode — loaded', WASM_PATH);
  } else {
    loadScopesFallback();
    console.log('[AgentIdP] OPA policy engine: fallback mode — loaded', SCOPES_PATH);
  }

  /**
   * Express middleware that authorises the current request against the OPA policy.
   *
   * Prerequisites: `authMiddleware` must have run first to populate `req.user`.
   *
   * @param req - Express request (must have `req.user` populated by auth middleware).
   * @param _res - Express response (unused).
   * @param next - Express next function.
   */
  const handler: RequestHandler = (req, _res, next): void => {
    try {
      if (!req.user) {
        // Auth middleware should have already rejected unauthenticated requests;
        // this is a safeguard against misconfigured middleware ordering.
        next(new AuthorizationError('Request is not authenticated.'));
        return;
      }

      const input: OpaInput = {
        method: req.method,
        // Use baseUrl + path to reconstruct the full path (req.path is relative
        // to the router's mount point; OPA policy patterns match full paths).
        path: req.baseUrl + req.path,
        scopes: req.user.scope.split(' '),
      };

      if (!evaluate(input)) {
        next(new AuthorizationError());
        return;
      }

      next();
    } catch (err) {
      next(err);
    }
  };

  return handler;
}

/**
 * Reloads the OPA policy from disk without restarting the server.
 *
 * Intended to be called from a SIGHUP handler. The reload strategy mirrors
 * startup: Wasm bundle is preferred; `scopes.json` is used as fallback.
 *
 * @returns Promise that resolves when the policy has been reloaded.
 * @throws Error if the reload fails (e.g. file is missing or malformed).
 */
export async function reloadOpaPolicy(): Promise<void> {
  const wasmLoaded = await loadWasmPolicy();

  if (wasmLoaded) {
    console.log('[AgentIdP] OPA policy reloaded: Wasm mode —', WASM_PATH);
  } else {
    loadScopesFallback();
    console.log('[AgentIdP] OPA policy reloaded: fallback mode —', SCOPES_PATH);
  }
}
