import { type Application, type Request, type Response } from 'express';
import { ConfigManager } from '../config-manager.js';
import { logger } from '../logger.js';

/**
 * Backend list response for GET /api/workers/:workerId/backends.
 *
 * Direct workers always return { source: 'direct', backends: [] } because
 * the worker itself IS the node — there is no proxy layer to fan out to.
 * Proxy workers (proxy: true) return the deployment list reported by the
 * upstream proxy's /v1/models endpoint.
 *
 * See docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
 */
export interface WorkerBackendsResponse {
  source: 'direct' | 'proxy';
  proxyType?: 'litellm';
  backends: Array<{
    id: string;
    model: string | null;
    online: boolean;
  }>;
  /** Set only when the upstream probe failed; UI uses this to render a degraded badge. */
  error?: string;
}

interface BackendsCacheEntry {
  expiresAt: number;
  payload: WorkerBackendsResponse;
}

/**
 * In-memory cache for proxy /v1/models lookups. Prevents the UI (which may
 * mount PetsPanel multiple times per session) from hammering the upstream
 * proxy. 60s matches the design doc and keeps backend list "fresh enough"
 * without coupling to a hard refresh button.
 */
const BACKENDS_CACHE_TTL_MS = 60_000;
const backendsCache = new Map<string, BackendsCacheEntry>();

/** Test hook: clear cache between tests so each case starts fresh. */
export function __clearWorkerBackendsCache(): void {
  backendsCache.clear();
}

/**
 * Top-level keys that v2 `PUT /api/config` rejects with 400. The migration
 * to the v2 shape (design doc 2026-05-21) treats these as authored-in-the-
 * wrong-shape errors: the UI is expected to send `llm.*` and `storage.*`
 * instead. Returning 400 here (rather than silently dropping them in
 * config-manager) gives the UI an actionable error message instead of a
 * confusing "save succeeded but nothing changed" outcome.
 */
const V2_REJECTED_TOP_LEVEL_KEYS: Record<string, string> = {
  provider: "use 'llm.*' instead (provider block removed in config v2; run scripts/migrate-config.sh to convert)",
  worktreeDir: "use 'storage.worktreeDir' instead (flat storage keys removed in config v2)",
  customPiecesDir: "use 'storage.customPiecesDir' instead (flat storage keys removed in config v2)",
  userFolderRoot: "use 'storage.userFolderRoot' instead (flat storage keys removed in config v2)",
};

function rejectLegacyV2Body(body: Record<string, unknown>): { key: string; message: string } | null {
  for (const [key, message] of Object.entries(V2_REJECTED_TOP_LEVEL_KEYS)) {
    if (key in body) return { key, message };
  }
  // tools.taskUploadMaxSizeMb / tools.trashRetentionDays migrated into
  // storage.* — reject them too so the UI doesn't silently keep writing
  // the old key.
  const tools = (body as any).tools;
  if (tools && typeof tools === 'object') {
    if ('taskUploadMaxSizeMb' in tools) {
      return {
        key: 'tools.taskUploadMaxSizeMb',
        message: "use 'storage.taskUploadMaxSizeMb' instead (moved out of tools.* in config v2)",
      };
    }
    if ('trashRetentionDays' in tools) {
      return {
        key: 'tools.trashRetentionDays',
        message: "use 'storage.trashRetentionDays' instead (moved out of tools.* in config v2)",
      };
    }
  }
  return null;
}

export function mountConfigApi(app: Application, configManager: ConfigManager): void {
  app.get('/api/config', (_req: Request, res: Response) => {
    const { config, etag, overriddenByEnv } = configManager.getConfigForApi();
    res.set('ETag', etag);
    res.json({ config, overriddenByEnv });
  });

  app.put('/api/config', (req: Request, res: Response) => {
    if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) {
      res.status(400).json({ ok: false, error: 'Request body must be a JSON object' }); return;
    }
    const rejected = rejectLegacyV2Body(req.body as Record<string, unknown>);
    if (rejected) {
      logger.warn(`[config-api] PUT /api/config rejected v1-shaped key '${rejected.key}'`);
      res.status(400).json({
        ok: false,
        error: `legacy config key '${rejected.key}' is no longer accepted; ${rejected.message}`,
        rejectedKey: rejected.key,
      });
      return;
    }
    const etag = req.headers['if-match'] as string | undefined;
    const result = configManager.updateConfig(req.body, etag);
    if (!result.ok) {
      const status = (result as any).conflict ? 409 : 400;
      res.status(status).json(result);
      return;
    }
    res.json({ ok: true });
  });

  app.post('/api/config/reload', (_req: Request, res: Response) => {
    try {
      configManager.reloadFromFile();
      res.json({ ok: true });
    } catch (e) {
      res.status(500).json({ ok: false, message: String(e) });
    }
  });

  app.get('/api/workers', (_req: Request, res: Response) => {
    const cfg = configManager.getConfig();
    const workers = (cfg.provider.workers ?? [])
      .filter(w => typeof w.id === 'string' && w.id.length > 0)
      .map(w => ({
        id: w.id,
        endpoint: w.endpoint ?? null,
        model: w.model ?? null,
        roles: Array.isArray(w.roles) ? w.roles : [],
        enabled: w.enabled !== false,
        proxy: w.proxy === true,
        proxyType: w.proxy === true ? (w.proxyType ?? 'litellm') : undefined,
      }));
    res.json({ workers });
  });

  // GET /api/workers/:workerId/backends
  //
  // Returns the physical backends behind a worker. For direct workers this
  // is a trivial empty list (the worker IS the node). For proxy workers
  // (proxy: true), we proxy to <endpoint>/v1/models and translate the
  // result into a uniform shape. Cached for 60s per worker so the panel
  // can be re-rendered freely without hammering the upstream.
  app.get('/api/workers/:workerId/backends', async (req: Request, res: Response) => {
    const workerId = String(req.params['workerId'] ?? '');
    const cfg = configManager.getConfig();
    const worker = (cfg.provider.workers ?? []).find(w => w.id === workerId);
    if (!worker) {
      res.status(404).json({ error: 'worker not found' });
      return;
    }

    if (worker.proxy !== true) {
      res.json({ source: 'direct', backends: [] } satisfies WorkerBackendsResponse);
      return;
    }

    const cacheKey = `${workerId}|${worker.endpoint}`;
    const cached = backendsCache.get(cacheKey);
    if (cached && cached.expiresAt > Date.now()) {
      res.json(cached.payload);
      return;
    }

    try {
      const payload = await fetchProxyBackends(worker.endpoint, worker.apiKey);
      backendsCache.set(cacheKey, { expiresAt: Date.now() + BACKENDS_CACHE_TTL_MS, payload });
      res.json(payload);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      logger.warn(`[config-api] /api/workers/${workerId}/backends failed: ${message}`);
      const payload: WorkerBackendsResponse = {
        source: 'proxy',
        proxyType: 'litellm',
        backends: [],
        error: message,
      };
      // Cache the failure briefly so a flapping upstream doesn't cause a
      // request storm. Shorter than success TTL on purpose.
      backendsCache.set(cacheKey, { expiresAt: Date.now() + 10_000, payload });
      res.status(502).json(payload);
    }
  });
}

interface LiteLLMModelEntry {
  id?: unknown;
  // LiteLLM's /v1/models response includes a few non-standard fields
  // alongside the OpenAI-shape entries; we only consume `id`.
  [key: string]: unknown;
}

/**
 * Fetch the physical backend list from a LiteLLM-style proxy.
 *
 * Calls `<endpoint>/v1/models` and converts each entry into a NodeStatus-
 * compatible backend record. v1 treats every returned id as `online: true`;
 * Phase B's BackendStatusRegistry will replace this with /health-derived
 * status when it lands.
 *
 * Exported for unit tests to drive end-to-end without a live proxy.
 */
export async function fetchProxyBackends(endpoint: string, apiKey?: string): Promise<WorkerBackendsResponse> {
  // Defense-in-depth: validate the endpoint URL before we touch it. Without
  // this guard, an admin (or a config write bug) could set the endpoint to
  // `file://...`, `data:...`, or `javascript:...` and we'd happily ship the
  // worker's apiKey as a Bearer token to whatever fetch() interprets it as.
  // /api/workers is admin-adjacent but the apiKey here may be a shared GPU
  // pool credential — leaking it to an arbitrary scheme is a credential
  // exfil primitive, not just a logic bug.
  //
  // Allowlist http: and https:. v1 doesn't enforce a host allowlist (admin
  // is trusted to point at the right proxy); scheme validation alone closes
  // the credential-leak class.
  let parsed: URL;
  try {
    parsed = new URL(endpoint);
  } catch {
    throw new Error(`invalid endpoint URL: ${endpoint}`);
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new Error(`unsupported endpoint scheme: ${parsed.protocol} (only http: and https: are allowed)`);
  }

  // /v1/models is the canonical OpenAI-compatible discovery endpoint.
  // LiteLLM honors it and returns the union of all configured deployments
  // visible under the caller's virtual key.
  const trimmed = endpoint.replace(/\/+$/, '');
  const url = `${trimmed}/models`;
  const headers: Record<string, string> = { Accept: 'application/json' };
  if (apiKey) {
    headers['Authorization'] = `Bearer ${apiKey}`;
  }
  const res = await fetch(url, { method: 'GET', headers });
  if (!res.ok) {
    throw new Error(`proxy /v1/models returned HTTP ${res.status}`);
  }
  const body = await res.json() as { data?: LiteLLMModelEntry[] } | LiteLLMModelEntry[];
  const data: LiteLLMModelEntry[] = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];

  const seen = new Set<string>();
  const backends: WorkerBackendsResponse['backends'] = [];
  for (const entry of data) {
    const id = typeof entry?.['id'] === 'string' ? (entry['id'] as string).trim() : '';
    if (!id || seen.has(id)) continue;
    seen.add(id);
    // LiteLLM populates `id` with the model alias users request; deployment
    // detail (litellm_params.model) is only returned via the admin
    // `/model/info` endpoint, which requires admin auth. v1 surfaces just
    // the alias; richer detail can be added in Phase B without changing
    // the response shape.
    backends.push({ id, model: id, online: true });
  }
  return { source: 'proxy', proxyType: 'litellm', backends };
}
