/**
 * Generic fetch+cache helper for `forge-public-info` resources.
 *
 * Pattern: GitHub-hosted JSON files live under
 *   `<publicInfoRepoUrl>/<resourceDir>/<file>.json`
 *
 * On read:
 *   1. If a fresh cached copy exists (< TTL), return it.
 *   2. Otherwise fetch from the configured `publicInfoRepoUrl`.
 *   3. On any error (offline, 4xx, malformed JSON), return the bundled
 *      fallback so the UI never breaks.
 *
 * Cache lives at `<dataDir>/public-info-cache/<resource>.json`. Each
 * file has a sidecar `<resource>.meta.json` carrying the fetch
 * timestamp; we don't trust HTTP `Last-Modified` because GitHub raw
 * serves through a CDN that strips it.
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getDataDir } from '../dirs';
import { loadSettings } from '../settings';

const DEFAULT_REPO_URL = 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 5000;

interface CacheMeta { fetchedAt: number; etag?: string }

function cacheDir(): string {
  const dir = join(getDataDir(), 'public-info-cache');
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  return dir;
}

function repoUrl(): string {
  return (loadSettings() as any).publicInfoRepoUrl || DEFAULT_REPO_URL;
}

function cacheKey(resourcePath: string): string {
  // public/info/models/registry.json → models-registry
  return resourcePath.replace(/\.json$/, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+|-+$/g, '');
}

function readCache(resourcePath: string): { data: unknown; meta: CacheMeta } | null {
  const key = cacheKey(resourcePath);
  const dataPath = join(cacheDir(), `${key}.json`);
  const metaPath = join(cacheDir(), `${key}.meta.json`);
  if (!existsSync(dataPath) || !existsSync(metaPath)) return null;
  try {
    return {
      data: JSON.parse(readFileSync(dataPath, 'utf-8')),
      meta: JSON.parse(readFileSync(metaPath, 'utf-8')),
    };
  } catch {
    return null;
  }
}

function writeCache(resourcePath: string, data: unknown): void {
  const key = cacheKey(resourcePath);
  try {
    writeFileSync(join(cacheDir(), `${key}.json`), JSON.stringify(data, null, 2), 'utf-8');
    writeFileSync(
      join(cacheDir(), `${key}.meta.json`),
      JSON.stringify({ fetchedAt: Date.now() } satisfies CacheMeta, null, 2),
      'utf-8',
    );
  } catch (err) {
    console.warn('[public-info] cache write failed:', err instanceof Error ? err.message : err);
  }
}

/**
 * Fetch a public-info resource with built-in cache + fallback.
 *
 * @param resourcePath  Path under the repo, e.g. `"models/registry.json"`
 * @param fallback      Used when network + cache both fail
 * @param opts.forceRefresh  Skip cache TTL check (e.g., user-triggered refresh)
 */
export async function fetchPublicInfo<T>(
  resourcePath: string,
  fallback: T,
  opts: { forceRefresh?: boolean } = {},
): Promise<T> {
  const now = Date.now();

  // 1. Cache hit (fresh)
  if (!opts.forceRefresh) {
    const cached = readCache(resourcePath);
    if (cached && now - cached.meta.fetchedAt < CACHE_TTL_MS) {
      return cached.data as T;
    }
  }

  // 2. Network fetch
  const url = `${repoUrl()}/${resourcePath}`;
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
    if (res.ok) {
      const data = await res.json();
      writeCache(resourcePath, data);
      return data as T;
    }
    console.warn(`[public-info] ${url} returned HTTP ${res.status}, using fallback`);
  } catch (err) {
    console.warn(`[public-info] ${url} fetch failed:`, err instanceof Error ? err.message : err);
  }

  // 3. Stale cache (better than fallback)
  const cached = readCache(resourcePath);
  if (cached) return cached.data as T;

  // 4. Bundled fallback
  return fallback;
}
