// v2.x: provider-aware model-context cache (W3). Replaces the dead
// `parsed.timings.n_ctx` capture in inference.ts / compaction.ts —
// llama-server's streaming completion never emits n_ctx in timings (verified
// empirically: timings carries prompt_n / predicted_n / *_ms / *_per_second
// only). The authoritative source is the provider's
// /upstream/<wireModelId>/props endpoint at .default_generation_settings.n_ctx.
//
// Cache design:
//   - Keys are the full composite model id (provider/model) so two providers
//     serving the same wire model name never share cache entries (D-2).
//   - Positive entries (n_ctx + total_slots) have no TTL. A model's context
//     size doesn't change while the provider is running; an admin endpoint
//     can invalidateModelContext() if it ever does.
//   - Negative entries (failed fetch) have a 60s TTL so a misconfigured or
//     down model doesn't get hammered every inference turn, but recovers
//     within a minute once the upstream comes back.
//   - 3s AbortController timeout on the fetch — long enough for a healthy
//     upstream, short enough that a stuck upstream doesn't block the
//     ctx_max UPDATE that follows.
//
// v1.x legacy: previously keyed by bare wire id and used a process-wide
// LLAMA_SWAP_URL. Now resolved per-call via the provider registry.

import { resolveModelProvider } from './inference/provider.js';

export interface ModelContext {
  n_ctx: number;
}

const NEGATIVE_TTL_MS = 60_000;
const FETCH_TIMEOUT_MS = 3_000;

const positiveCache = new Map<string, ModelContext>();
// Value is the unix-ms timestamp of the last failed fetch. Used to gate
// re-fetches within the 60s window.
const negativeCache = new Map<string, number>();

// Stored config for provider-aware resolution. Supports both the legacy
// { llamaSwapUrl: string } shape (for tests) and the full Config shape.
let storedConfig: ConfigForModelContext | null = null;

/** Config fields needed for model-context provider resolution. */
type ConfigForModelContext = {
  LLAMA_SWAP_URL: string;
  DEEPSEEK_API_KEY?: string;
  DEEPSEEK_BASE_URL?: string;
  ANTHROPIC_API_KEY?: string;
  ANTHROPIC_BASE_URL?: string;
};

/**
 * Configure the module for model-context lookups.
 *
 * Accepts either the full server Config (production) or the legacy
 * `{ llamaSwapUrl }` shape (tests). The full Config is preferred so
 * getModelContext can resolve composite model ids through the provider
 * registry.
 */
export function configureModelContext(
  opts: ConfigForModelContext | { llamaSwapUrl: string },
): void {
  // Legacy test helper: { llamaSwapUrl } → synthesize a minimal config.
  if ('llamaSwapUrl' in opts && typeof opts.llamaSwapUrl === 'string') {
    storedConfig = { LLAMA_SWAP_URL: opts.llamaSwapUrl };
    return;
  }
  storedConfig = opts as ConfigForModelContext;
}

// vDeepSeek: DeepSeek models don't have a /upstream/<model>/props endpoint.
// Return a reasonable default context so compaction estimates work.
const DEEPSEEK_DEFAULT_N_CTX = 1_000_000;

// Anthropic Claude models also have no props endpoint. Static windows: the
// 4.x Opus/Sonnet family is 1M; Haiku is 200K.
const ANTHROPIC_HAIKU_N_CTX = 200_000;
const ANTHROPIC_DEFAULT_N_CTX = 1_000_000;

/** Static context window for hosted providers without a props endpoint, or null. */
function staticHostedNCtx(resolved: ReturnType<typeof resolveModelProvider>): number | null {
  if (resolved.providerId === 'deepseek') return DEEPSEEK_DEFAULT_N_CTX;
  if (resolved.route === 'anthropic') {
    return resolved.wireModelId.includes('haiku') ? ANTHROPIC_HAIKU_N_CTX : ANTHROPIC_DEFAULT_N_CTX;
  }
  return null;
}

export async function getModelContext(model: string): Promise<ModelContext | null> {
  // Resolve the model through the provider-aware resolver. For composite
  // "provider/model" ids, this finds the correct provider's baseUrl. For
  // bare legacy ids, it falls back to the default provider.
  const config = storedConfig;
  if (!config) {
    // Module not initialized. Defensive — index.ts calls
    // configureModelContext at startup; if a test forgets, fail closed so
    // the chat still works (ctx_max stays null, UI degrades gracefully).
    negativeCache.set(model, Date.now());
    return null;
  }

  const resolved = resolveModelProvider(model, config);

  // Hosted providers (DeepSeek, Anthropic) have no /upstream/<model>/props
  // endpoint — use a static window so compaction doesn't fall to the
  // buffer-only path with tiny limits.
  const staticCtx = staticHostedNCtx(resolved);
  if (staticCtx !== null) return { n_ctx: staticCtx };

  // P7: orphaned auto:* session with no gateway configured — no props endpoint
  // to query. Negative-cache and return null; compaction degrades gracefully.
  if (resolved.route === 'gateway_error') {
    negativeCache.set(model, Date.now());
    return null;
  }

  // P7: gateway route — baseUrl is the control gateway, which exposes
  // /upstream/<virtualModel>/props (it proxies the chosen candidate's props).
  // The normal fetch path below handles it without special-casing.

  // Cache key is the full composite id to prevent cross-provider cache
  // poisoning for duplicate wire model names (D-2, design §5.3).
  const cacheKey = `${resolved.providerId}/${resolved.wireModelId}`;

  // 1. Positive cache hit — no TTL check, model n_ctx is invariant.
  const pos = positiveCache.get(cacheKey);
  if (pos) return pos;

  // 2. Negative cache hit within TTL — return null without refetching.
  // Stale negative entries (older than the TTL) fall through to a fresh
  // attempt below; we don't delete them eagerly because the next successful
  // fetch will overwrite via the positive map and the negative entry
  // becomes irrelevant.
  const negTs = negativeCache.get(cacheKey);
  if (negTs !== undefined && Date.now() - negTs < NEGATIVE_TTL_MS) {
    return null;
  }

  // 3. Fetch with timeout. AbortController fires after FETCH_TIMEOUT_MS;
  // both the timeout path and a fetch reject end up in the catch below
  // and produce a negative cache entry.
  //
  // Strip the provider prefix: fetch from
  // <provider.baseUrl>/upstream/<wireModelId>/props (design §5.3).
  const url = `${resolved.baseUrl.replace(/\/+$/, '')}/upstream/${encodeURIComponent(resolved.wireModelId)}/props`;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, { signal: controller.signal });
    clearTimeout(timer);
    if (!res.ok) {
      negativeCache.set(cacheKey, Date.now());
      return null;
    }
    const body = (await res.json()) as {
      default_generation_settings?: { n_ctx?: number };
    };
    const n_ctx = body?.default_generation_settings?.n_ctx;
    if (typeof n_ctx !== 'number' || n_ctx <= 0) {
      negativeCache.set(cacheKey, Date.now());
      return null;
    }
    const entry: ModelContext = { n_ctx };
    positiveCache.set(cacheKey, entry);
    // Clear any stale negative entry so a future query sees the positive
    // hit cleanly (otherwise the negative TTL never expires from the map).
    negativeCache.delete(cacheKey);
    return entry;
  } catch {
    clearTimeout(timer);
    negativeCache.set(cacheKey, Date.now());
    return null;
  }
}

export function invalidateModelContext(model?: string): void {
  if (model === undefined) {
    positiveCache.clear();
    negativeCache.clear();
  } else {
    // Resolve to composite cache key. If the model is already composite
    // (contains '/'), it's used directly. Otherwise, resolve through the
    // provider registry to find the composite key. This keeps backward
    // compat with callers passing bare model names.
    let cacheKey = model;
    if (storedConfig && !model.includes('/')) {
      const resolved = resolveModelProvider(model, storedConfig);
      cacheKey = `${resolved.providerId}/${resolved.wireModelId}`;
    }
    positiveCache.delete(cacheKey);
    negativeCache.delete(cacheKey);
  }
}
