/**
 * Entitlement enforcement middleware for the Vercel API gateway.
 *
 * Reads cached entitlements from Redis (raw keys, no deployment prefix) with
 * Convex fallback on cache miss. Returns a 403 Response for tier-gated endpoints
 * when the user lacks the required tier.
 *
 * Fail-closed behavior:
 *   - No userId header on a gated endpoint -> 403 (authentication required)
 *   - Redis miss + Convex failure -> 403 (unable to verify entitlements)
 *   - Endpoint not in ENDPOINT_ENTITLEMENTS -> allow (unrestricted)
 */

import { getCachedJson, setCachedJson } from './redis';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface CachedEntitlements {
  planKey: string;
  features: {
    tier: number;
    apiAccess: boolean;
    apiRateLimit: number;
    maxDashboards: number;
    prioritySupport: boolean;
    exportFormats: string[];
    /**
     * Pro MCP access (plan 2026-05-10-001). Undefined on legacy entitlement
     * rows written before the catalog field landed; every consumer
     * (gateway HMAC verifier, isCallerPremium, MCP edge handler) treats
     * undefined as `false` — fail-closed. The Dodo webhook repopulates
     * this on the next subscription event.
     */
    mcpAccess?: boolean;
    /**
     * Per-account daily REST allowance (#3199). The rate-limit layer meters
     * but never rejects at this value; the hard ceiling is 10×. `-1` =
     * unlimited. Unlike `mcpAccess`, consumers treat `undefined` as
     * **no daily limit (fail-OPEN)** — a stale/legacy cache must not punish
     * a paying customer. NOT added to the cache-staleness gate below for
     * that reason (forcing a re-fetch would contradict fail-open).
     */
    apiDailyAllowance?: number;
  };
  validUntil: number;
}

export interface EntitlementCheckResult {
  response: Response | null;
  entitlements: CachedEntitlements | null;
}

export interface EntitlementCheckOptions {
  clerkRole?: 'free' | 'pro' | null;
}

// ---------------------------------------------------------------------------
// Endpoint-to-tier map (replaces PREMIUM_RPC_PATHS)
// ---------------------------------------------------------------------------

/**
 * Maps API endpoints to the minimum tier required for access.
 * Tier hierarchy: 0=free, 1=pro, 2=api, 3=enterprise.
 *
 * Adding a new gated endpoint = adding one line to this map.
 * Endpoints NOT in this map are unrestricted.
 *
 * Stock-analysis endpoints sit at tier 1 (Pro) — the productCatalog markets
 * "AI stock analysis & backtesting" as a Pro feature, and these paths are
 * also in PREMIUM_RPC_PATHS where the legacy bearer gate accepts tier >= 1.
 * Tier-2 here would have made the new gate stricter than the legacy one and
 * 403'd real Pro subscribers calling via Clerk session (no tester key).
 */
const ENDPOINT_ENTITLEMENTS: Record<string, number> = {
  '/api/forecast/v1/trigger-simulation': 1,
  '/api/intelligence/v1/classify-event': 1,
  '/api/market/v1/analyze-stock': 1,
  '/api/market/v1/get-stock-analysis-history': 1,
  '/api/market/v1/backtest-stock': 1,
  '/api/market/v1/list-stored-stock-backtests': 1,
  '/api/sanctions/v1/list-sanctions-pressure': 1,
  '/api/scenario/v1/run-scenario': 1,
  '/api/scenario/v1/get-scenario-status': 1,
  '/api/supply-chain/v1/get-country-chokepoint-index': 1,
  '/api/supply-chain/v1/get-bypass-options': 1,
  '/api/supply-chain/v1/get-country-cost-shock': 1,
  '/api/supply-chain/v1/get-route-explorer-lane': 1,
  '/api/supply-chain/v1/get-route-impact': 1,
  '/api/supply-chain/v1/get-country-products': 1,
  '/api/supply-chain/v1/get-multi-sector-cost-shock': 1,
  '/api/supply-chain/v1/get-sector-dependency': 1,
  '/api/trade/v1/list-comtrade-flows': 1,
  '/api/trade/v1/get-tariff-trends': 1,
};

const CONVEX_INTERNAL_ENTITLEMENTS_PATH = '/api/internal-entitlements';
let _didWarnMissingConvexSharedSecret = false;

function getConvexSharedSecret(): string {
  const secret = process.env.CONVEX_SERVER_SHARED_SECRET ?? '';
  if (!secret && !_didWarnMissingConvexSharedSecret) {
    _didWarnMissingConvexSharedSecret = true;
    console.warn('[entitlement-check] CONVEX_SERVER_SHARED_SECRET not set; Convex fallback disabled');
  }
  return secret;
}

// ---------------------------------------------------------------------------
// Request coalescing (P1-6: Cache stampede mitigation)
// ---------------------------------------------------------------------------

const _inFlight = new Map<string, Promise<CachedEntitlements | null>>();

// ---------------------------------------------------------------------------
// Environment-aware Redis key prefix (P2-3)
// ---------------------------------------------------------------------------

const ENV_PREFIX = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live' : 'test';

// Cache TTL: 15 min — short enough that subscription expiry is reflected promptly (P2-5)
const ENTITLEMENT_CACHE_TTL_SECONDS = 900;

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Returns the minimum tier required for a given endpoint pathname.
 * Returns null if the endpoint is unrestricted (not in the map).
 */
export function getRequiredTier(pathname: string): number | null {
  return ENDPOINT_ENTITLEMENTS[pathname] ?? null;
}

/**
 * Fetches entitlements for a user. Tries Redis cache first (raw key),
 * then falls back to ConvexHttpClient query on cache miss.
 *
 * Returns null on any failure (fail-closed: caller must treat null as no entitlements).
 *
 * Uses request coalescing to prevent cache stampede: concurrent requests for
 * the same userId share a single in-flight promise.
 */
export async function getEntitlements(userId: string): Promise<CachedEntitlements | null> {
  const existing = _inFlight.get(userId);
  if (existing) return existing;

  const promise = _getEntitlementsImpl(userId);
  _inFlight.set(userId, promise);
  try {
    return await promise;
  } finally {
    _inFlight.delete(userId);
  }
}

async function _getEntitlementsImpl(userId: string): Promise<CachedEntitlements | null> {
  try {
    // Redis cache check (raw=true: entitlements use user-scoped keys, no deployment prefix)
    const cached = await getCachedJson(`entitlements:${ENV_PREFIX}:${userId}`, true);

    if (cached && typeof cached === 'object') {
      const ent = cached as CachedEntitlements;
      // Only use cached data if it hasn't expired AND has the post-U10 shape.
      //
      // Legacy cache entries written before plan 2026-05-10-001 U10 lack the
      // `features.mcpAccess` field. The Convex read path read-time-merges
      // catalog defaults (convex/entitlements.ts:50), but bare-cache reads
      // bypass that merge — paying users with hot pre-deploy cache entries
      // would see `mcpAccess !== true` at the grant/MCP gates and get
      // blocked for up to 15 min until the cache expires. Treating
      // missing-field cache entries as stale falls through to Convex,
      // which returns the merged shape and rewrites the cache with the
      // post-U10 layout. Self-healing, bounded to one extra Convex
      // round-trip per affected user during the migration window.
      // Reviewer round-2 P2 (cache layer).
      if (
        ent.validUntil >= Date.now() &&
        typeof (ent.features as { mcpAccess?: boolean }).mcpAccess === 'boolean'
      ) {
        return ent;
      }
      // Expired OR legacy shape -- fall through to Convex.
    }

    // Convex fallback on cache miss or expired cache
    const convexSiteUrl = process.env.CONVEX_SITE_URL;
    const convexSharedSecret = getConvexSharedSecret();
    if (!convexSiteUrl || !convexSharedSecret) return null;

    const response = await fetch(`${convexSiteUrl}${CONVEX_INTERNAL_ENTITLEMENTS_PATH}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'worldmonitor-gateway/1.0',
        'x-convex-shared-secret': convexSharedSecret,
      },
      body: JSON.stringify({ userId }),
      signal: AbortSignal.timeout(3_000),
    });
    if (!response.ok) return null;
    const result = await response.json() as CachedEntitlements | null;

    if (result) {
      // Populate Redis cache for subsequent requests (15-min TTL, raw key).
      //
      // Cache-write failures must NOT collapse "entitlement confirmed by Convex"
      // into the null-means-no-entitlement return. Today setCachedJson swallows
      // its own Upstash errors via an internal try/catch (server/_shared/redis.ts),
      // but that contract is fragile — the tauri-sidecar dynamic import path at
      // redis.ts:142-146 is OUTSIDE the inner try/catch, and any future code
      // motion could let other errors propagate. Wrap explicitly here so the
      // property "Convex said yes ⇒ caller sees yes" is local and load-bearing.
      // Without this, an Upstash hiccup would 403 every paying customer on the
      // very call paths this file gates — the same shape PR #3505 fixed for the
      // Clerk-only-no-Convex outlier in api/widget-agent.ts.
      try {
        await setCachedJson(`entitlements:${ENV_PREFIX}:${userId}`, result, ENTITLEMENT_CACHE_TTL_SECONDS, true);
      } catch (cacheErr) {
        console.warn('[entitlement-check] cache write failed (non-fatal):', cacheErr instanceof Error ? cacheErr.message : String(cacheErr));
      }
      return result as CachedEntitlements;
    }

    return null;
  } catch (err) {
    // Fail-closed: any error in entitlement lookup returns null (caller blocks the request)
    console.warn('[entitlement-check] getEntitlements failed:', err instanceof Error ? err.message : String(err));
    return null;
  }
}

/**
 * Checks whether the current request is allowed based on tier entitlements.
 *
 * Returns:
 *   - null if the request is allowed (unrestricted endpoint or sufficient tier)
 *   - a 403 Response if the user is unauthenticated, entitlements cannot be verified,
 *     or the user's tier is below the required tier (fail-closed)
 */
export async function checkEntitlement(
  userId: string | null,
  pathname: string,
  corsHeaders: Record<string, string>,
  options: EntitlementCheckOptions = {},
): Promise<Response | null> {
  const result = await checkEntitlementDetailed(userId, pathname, corsHeaders, options);
  return result.response;
}

/**
 * Same authorization decision as checkEntitlement(), plus the resolved
 * entitlement row when one was available. Gateway telemetry uses this so
 * allow/deny events reflect the exact plan/tier that drove the decision.
 */
export async function checkEntitlementDetailed(
  userId: string | null,
  pathname: string,
  corsHeaders: Record<string, string>,
  options: EntitlementCheckOptions = {},
): Promise<EntitlementCheckResult> {
  const requiredTier = getRequiredTier(pathname);
  if (requiredTier === null) {
    // Unrestricted endpoint -- no check needed
    return { response: null, entitlements: null };
  }

  if (!userId) {
    return {
      response: new Response(
        JSON.stringify({ error: 'Authentication required', requiredTier }),
        { status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
      ),
      entitlements: null,
    };
  }

  // Preserve the legacy Pro bearer contract for tier-1 gates. Complimentary,
  // tester, and legacy Clerk-role grants can have no Convex entitlement row,
  // while the frontend still unlocks Pro panels for role='pro'.
  if (options.clerkRole === 'pro' && requiredTier <= 1) {
    return { response: null, entitlements: null };
  }

  const ent = await getEntitlements(userId);
  if (!ent) {
    // Fail-closed: unable to verify entitlements -> block the request
    return {
      response: new Response(
        JSON.stringify({ error: 'Unable to verify entitlements', requiredTier }),
        { status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
      ),
      entitlements: null,
    };
  }

  if (ent.features.tier >= requiredTier) {
    // User has sufficient tier -- allow
    return { response: null, entitlements: ent };
  }

  // User lacks required tier -- return 403
  return {
    response: new Response(
      JSON.stringify({
        error: 'Upgrade required',
        requiredTier,
        currentTier: ent.features.tier,
        planKey: ent.planKey,
      }),
      {
        status: 403,
        headers: { 'Content-Type': 'application/json', ...corsHeaders },
      },
    ),
    entitlements: ent,
  };
}
