/**
 * Supabase-backed cache wrapper around upstream Odds API and backend
 * grading-engine calls. Keeps user-facing requests off the rate-limited
 * upstream API (500 req/mo on free tier) by serving from a 5-minute TTL
 * cache row in `odds_cache`.
 *
 * Cache key pattern: `{sport}:{data_type}:{date?}`
 *   e.g. `nba:games:2026-05-18`, `mlb:props:2026-05-18`, `wnba:games:today`
 *
 * Failure mode: if Supabase is unreachable, we still call the loader so
 * a fresh response is returned. If both Supabase AND the loader fail,
 * the caller gets the stale cache row (if any) or the loader's thrown
 * error.
 */

import { getServiceRoleSupabase } from '@/lib/supabase';

interface CacheEntry<T> {
  payload: T;
  fetched_at: string;
  expires_at: string;
}

const DEFAULT_TTL_SECONDS = 300; // 5 min

export interface CachedFetchOptions<T> {
  /**
   * Unique cache key. Reuse it across calls that want the same data.
   */
  key: string;
  sport: string;
  dataType: string;
  /** How long the row stays fresh. Defaults to 300s. */
  ttlSeconds?: number;
  /** Loader called on a miss. Must return a value or throw. */
  loader: () => Promise<T>;
  /**
   * If true, returns the cached row even after it has expired when
   * the loader throws. Defaults to true.
   */
  fallbackToStale?: boolean;
}

export async function cachedFetch<T>(opts: CachedFetchOptions<T>): Promise<T> {
  const sb = getServiceRoleSupabase();
  const now = new Date();
  const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;

  // 1. Try the cache.
  let cached: CacheEntry<T> | null = null;
  if (sb) {
    try {
      const { data } = await sb
        .from('odds_cache')
        .select('payload, fetched_at, expires_at')
        .eq('cache_key', opts.key)
        .maybeSingle();
      if (data) cached = data as CacheEntry<T>;
    } catch {
      /* fall through to loader */
    }
  }

  if (cached && new Date(cached.expires_at) > now) {
    return cached.payload;
  }

  // 2. Refresh via the loader.
  try {
    const fresh = await opts.loader();
    if (sb) {
      const expires = new Date(now.getTime() + ttl * 1000).toISOString();
      // upsert is racy but the conflict is harmless — last writer wins.
      await sb
        .from('odds_cache')
        .upsert(
          {
            cache_key: opts.key,
            sport: opts.sport,
            data_type: opts.dataType,
            payload: fresh as unknown as object,
            fetched_at: now.toISOString(),
            expires_at: expires,
          },
          { onConflict: 'cache_key' },
        );
    }
    return fresh;
  } catch (err) {
    if (opts.fallbackToStale !== false && cached) return cached.payload;
    throw err;
  }
}

/**
 * Wrap a `fetch` against the BACKEND_URL with the cache. Useful for
 * routes that pass-through to the Express grading engine but want to
 * absorb its load spikes.
 */
export async function cachedBackendJson<T>(
  key: string,
  sport: string,
  dataType: string,
  backendPath: string,
  ttlSeconds = DEFAULT_TTL_SECONDS,
): Promise<T> {
  const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
  return cachedFetch<T>({
    key,
    sport,
    dataType,
    ttlSeconds,
    loader: async () => {
      const res = await fetch(`${BACKEND_URL}${backendPath}`, {
        headers: { Accept: 'application/json' },
        // Force fresh from backend so we control the TTL ourselves.
        cache: 'no-store',
      });
      if (!res.ok) throw new Error(`backend ${backendPath} returned ${res.status}`);
      return (await res.json()) as T;
    },
  });
}

/**
 * Helper for daily-keyed caches.
 */
export function todayKey(sport: string, dataType: string): string {
  const d = new Date().toISOString().slice(0, 10);
  return `${sport.toLowerCase()}:${dataType}:${d}`;
}
