// ---------------------------------------------------------------------------
// platform-proxy.ts — forward unhandled /api/v1/skills/* requests from the
// Skill Studio (eval-server, port-hashed e.g. 3162) to the vskill-platform
// (default https://verified-skill.com — the production worker — for parity
//  with src/api/client.ts:10 DEFAULT_BASE_URL).
//
// Why this exists (0712 US-003 follow-up T-016A/B; default re-targeted in 0725):
//   The studio frontend (src/eval-ui) issues *relative* fetches such as
//   `/api/v1/skills/check-updates` and `/api/v1/skills/stream`. Those land
//   on the eval-server itself, which has no handler for them and returns
//   `{"error":"Not found"}` 404. Production deploys the studio behind the
//   platform (same origin), so relative URLs resolve to the platform; in
//   local dev the studio is served by the eval-server CLI (`vskill studio`),
//   which is a *different* process from the platform. This helper closes
//   the gap by transparently proxying any unhandled `/api/v1/skills/*` path
//   to the platform target.
//
// Design notes:
//   - Target URL is configurable via `VSKILL_PLATFORM_URL` env (default
//     `https://verified-skill.com`). Local-platform devs running their own
//     `wrangler dev` opt back in via `VSKILL_PLATFORM_URL=http://localhost:3017`.
//     Must include scheme + host (port optional for https).
//   - Method, headers (minus hop-by-hop), query string, and request body
//     stream are forwarded verbatim. Response status, headers, and body
//     stream are piped back so SSE (`text/event-stream`) connections stay
//     long-lived without buffering.
//   - Path prefix is preserved exactly (`/api/v1/skills/...`) — the
//     platform routes match the same paths.
//   - Connection failures upstream return a `502 Bad Gateway` JSON
//     envelope so the studio's fetch error handlers see a structured
//     response rather than a hung socket.
//
// SSE subscription ID-format contract (0736 / AC-US3-04)
// -------------------------------------------------------
// /api/v1/skills/stream is forwarded verbatim to the platform's UpdateHub.
// The `?skills=<csv>` query param MUST contain UUID (`Skill.id`) or public
// slug (`sk_published_<owner>/<repo>/<skill>`) identifiers — NOT the studio's
// local `<plugin>/<skill>` name format (e.g. `.claude/greet-anton`).
//
// The studio side resolves platform IDs via the `resolveSubscriptionIds()`
// helper (src/eval-ui/src/utils/resolveSubscriptionIds.ts) before opening
// the EventSource. Skills without a resolvable UUID or slug are omitted from
// the filter; the polling fallback (usePluginsPolling) covers them so no
// updates are lost. See useSkillUpdates.ts for the full hook-side contract.
//
// This proxy does NOT transform or validate the `?skills=` parameter — it
// forwards exactly what the studio sends. Correctness is the studio's
// responsibility (enforced in resolveSubscriptionIds + useSkillUpdates).
// ---------------------------------------------------------------------------

import * as http from "node:http";
import * as https from "node:https";
import { randomUUID } from "node:crypto";
import { getDefaultKeychain } from "../lib/keychain.js";

const DEFAULT_PLATFORM_URL = "https://verified-skill.com";

// Hop-by-hop headers per RFC 2616 §13.5.1 — never forward these on a proxy.
const HOP_BY_HOP = new Set([
  "connection",
  "keep-alive",
  "proxy-authenticate",
  "proxy-authorization",
  "te",
  "trailers",
  "transfer-encoding",
  "upgrade",
  "host",
  "content-length",
]);

export function getPlatformBaseUrl(): string {
  const raw = process.env.VSKILL_PLATFORM_URL;
  if (typeof raw === "string" && raw.length > 0) {
    return raw.replace(/\/$/, "");
  }
  return DEFAULT_PLATFORM_URL;
}

/**
 * Decide whether a given request URL should be proxied to the platform.
 *
 * Forwarded prefixes:
 *   - /api/v1/skills/*           — original contract (skill metadata, versions, stream)
 *   - /api/v1/studio/search      — find-palette query endpoint (0741 US-005)
 *   - /api/v1/studio/telemetry/  — fire-and-forget palette telemetry (0741 US-005)
 *   - /api/v1/stats              — trending fallback for empty queries (0741 US-005)
 *
 * Other `/api/...` paths remain owned by the eval-server (e.g. `/api/config`,
 * `/api/skills` legacy local route, `/api/agents`, `/api/workspace`).
 */
const PROXY_PREFIXES = [
  "/api/v1/skills/",
  "/api/v1/studio/search",
  "/api/v1/studio/telemetry/",
  "/api/v1/stats",
  // In-app submission queue (0856): the WebView POSTs /api/v1/submissions and
  // streams /api/v1/submissions/stream/[id] + GET /positions/[id]. Proxying
  // here keeps the CORS-free browser→localhost-only invariant and lets the
  // eval-server inject the user's vsk_* keychain token server-side so an
  // in-app submit is attributed to the signed-in user (not anonymous).
  //
  // Prefix has NO trailing slash so it matches BOTH the bare POST target
  // (`/api/v1/submissions`) and the sub-resource GETs
  // (`/api/v1/submissions/stream/<id>`, `/api/v1/submissions/positions/<id>`).
  // This is deliberately distinct from the trailing-slash prefixes below so
  // private/tenant/account paths keep their exact-segment semantics.
  "/api/v1/submissions",
  // Private (org-scoped) routes that must carry the user's GitHub bearer token
  // to the platform. The browser never sees this token; injection happens here.
  "/api/v1/private/",
  "/api/v1/tenants/",
  // 0836 US-003: account cabinet routes were previously fetched directly from
  // the WebView with `Authorization: Bearer <gho_*>` minted by the deleted
  // `account_get_token` IPC. Proxying them through the eval-server lets us
  // inject the bearer Rust-side from the keychain — the WebView never holds
  // the token. ALL /api/v1/account/* routes (profile, tokens, repos,
  // notifications, exports, delete, sign-out-all) are user-authenticated;
  // they all match AUTH_REQUIRED_PREFIXES below. If a future public
  // marketing route is added, route it under a different prefix (not
  // /api/v1/account/) so it doesn't pick up the bearer.
  "/api/v1/account/",
] as const;

/**
 * Path prefixes that require an `Authorization: Bearer <github-token>` header
 * to be injected at the proxy boundary. Public skill routes are intentionally
 * excluded — they must remain anonymous so unauthenticated tabs continue to
 * function in the public skill catalog.
 */
const AUTH_REQUIRED_PREFIXES = [
  // 0856: in-app submissions carry the user's vsk_* keychain token (NOT gho_*)
  // — see TOKEN_KIND_BY_PREFIX below. Any client-supplied Authorization is
  // stripped here so the browser can never spoof identity to the platform.
  "/api/v1/submissions",
  "/api/v1/private/",
  "/api/v1/tenants/",
  // 0836 US-003: every /api/v1/account/* route requires the user's bearer
  // (profile, repos, tokens, notifications, exports). Inject it here so the
  // WebView never sees a `gho_*` value.
  "/api/v1/account/",
] as const;

export function shouldProxyToPlatform(url: string | undefined): boolean {
  if (!url) return false;
  return PROXY_PREFIXES.some((p) => url.startsWith(p));
}

export function shouldInjectAuth(url: string | undefined): boolean {
  if (!url) return false;
  return AUTH_REQUIRED_PREFIXES.some((p) => url.startsWith(p));
}

// Two kinds of bearer token can be injected at the proxy boundary:
//   - "github" (gho_*): account/private/tenant routes. The platform validates
//     it as a GitHub OAuth token.
//   - "vskill" (vsk_*): the in-app submission queue (0856). The platform's
//     submissions handler attributes the submit to the signed-in user via the
//     vsk_* API token (requireUserOrGithubBearer), so an in-app submit is NOT
//     anonymous — injecting gho_* here would mis-attribute it.
type TokenKind = "github" | "vskill";

// Which token kind each AUTH_REQUIRED prefix needs. Default (and the only
// non-github entry) is the submissions prefix → vskill. Everything else uses
// the GitHub token as before. Kept as an explicit, ordered list so the
// longest/most-specific prefix wins and the selection is obvious at a glance.
const TOKEN_KIND_BY_PREFIX: ReadonlyArray<readonly [string, TokenKind]> = [
  ["/api/v1/submissions", "vskill"],
] as const;

/** Select the bearer-token kind for a given upstream path. */
export function tokenKindForPath(path: string | undefined): TokenKind {
  if (path) {
    for (const [prefix, kind] of TOKEN_KIND_BY_PREFIX) {
      if (path.startsWith(prefix)) return kind;
    }
  }
  return "github";
}

// In-process token cache so a burst of proxy requests doesn't repeatedly hit
// the OS keychain. The keychain itself is fast on macOS but can be slower on
// Linux libsecret.
//
// Cached PER token-kind: a gho_* request and a vsk_* request must never serve
// each other's cached value. Naively caching a single global token (the
// pre-0856 behaviour) would leak the wrong token across prefixes once the
// submissions path was added.
//
// 0836 hardening (F-005): TTL kept intentionally short (5s) so that a desktop
// `sign_out` IPC — which clears the Rust keychain in a separate process —
// is reflected by the Node-side eval-server within at most 5 seconds.
// Cross-process cache invalidation would require a control-channel from the
// Tauri shell into the sidecar; bounding the staleness window achieves the
// same UX guarantee with no extra plumbing.
const _cachedTokens: Partial<
  Record<TokenKind, { value: string | null; expiresAt: number }>
> = {};
const TOKEN_CACHE_MS = 5_000;

function readKeychainToken(kind: TokenKind): string | null {
  try {
    return kind === "vskill"
      ? getDefaultKeychain().getVskillToken()
      : getDefaultKeychain().getGitHubToken();
  } catch {
    return null;
  }
}

function readTokenForProxy(
  kind: TokenKind = "github",
  now: number = Date.now(),
): string | null {
  const cached = _cachedTokens[kind];
  if (cached && cached.expiresAt > now) return cached.value;
  const token = readKeychainToken(kind);
  _cachedTokens[kind] = { value: token, expiresAt: now + TOKEN_CACHE_MS };
  return token;
}

/**
 * Public invalidation hook so callers (e.g. an HTTP control endpoint, or a
 * future SIGUSR1 handler) can drop the cached token immediately on auth
 * state changes. Tests use this to reset between cases as well. Clears ALL
 * token kinds.
 */
export function invalidatePlatformProxyTokenCache(): void {
  for (const k of Object.keys(_cachedTokens) as TokenKind[]) {
    delete _cachedTokens[k];
  }
}

/** @deprecated Prefer `invalidatePlatformProxyTokenCache`. Kept for any
 * existing test imports until they migrate. */
export function _resetPlatformProxyAuthCacheForTests(): void {
  invalidatePlatformProxyTokenCache();
}

export interface PickHeadersOptions {
  /** Request path (e.g. "/api/v1/tenants/abc/skills"). */
  path?: string;
  /** Token provider override; defaults to in-process cached keychain read. */
  tokenProvider?: () => string | null;
}

export function pickHeadersForUpstream(
  src: http.IncomingHttpHeaders,
  opts: PickHeadersOptions = {},
): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(src)) {
    if (typeof v === "undefined") continue;
    if (HOP_BY_HOP.has(k.toLowerCase())) continue;
    // Strip any client-supplied Authorization on private/tenant paths — we
    // mint our own from the keychain. On other paths we pass through (the
    // existing public proxy never carried Authorization, so this is a no-op).
    if (k.toLowerCase() === "authorization" && opts.path && shouldInjectAuth(opts.path)) {
      continue;
    }
    out[k] = Array.isArray(v) ? v.join(", ") : String(v);
  }
  // Preserve client-IP intent for any future platform-side observability.
  const xff = out["x-forwarded-for"];
  out["x-forwarded-for"] = xff ? `${xff}, 127.0.0.1` : "127.0.0.1";

  if (opts.path && shouldInjectAuth(opts.path)) {
    // Per-prefix token kind: submissions → vsk_*, everything else → gho_*.
    // A test-supplied tokenProvider override always wins (kind-agnostic).
    const kind = tokenKindForPath(opts.path);
    const tokenProvider = opts.tokenProvider ?? (() => readTokenForProxy(kind));
    const token = tokenProvider();
    if (token) {
      out["authorization"] = `Bearer ${token}`;
    }
  }
  return out;
}

function pickHeadersForDownstream(
  src: http.IncomingHttpHeaders,
): Record<string, string | string[]> {
  const out: Record<string, string | string[]> = {};
  for (const [k, v] of Object.entries(src)) {
    if (typeof v === "undefined") continue;
    if (HOP_BY_HOP.has(k.toLowerCase())) continue;
    out[k] = v;
  }
  return out;
}

/**
 * Determine whether a given request/response pair is on the SSE path.
 *
 * Heuristic (cheap — runs on every request when VSKILL_DEBUG_SSE=1):
 *   - Path ends with `/stream` (e.g. `/api/v1/skills/stream`).
 *   - OR upstream response carries `content-type: text/event-stream`.
 *
 * The path heuristic alone is sufficient for the only SSE endpoint the studio
 * proxies today (`/api/v1/skills/stream`). The content-type fallback covers
 * forward-compat in case future SSE endpoints are added without a `/stream`
 * suffix.
 */
function isSsePathOrResponse(
  pathname: string,
  responseContentType: string | undefined,
): boolean {
  if (pathname.endsWith("/stream")) return true;
  if (
    typeof responseContentType === "string" &&
    responseContentType.toLowerCase().includes("text/event-stream")
  ) {
    return true;
  }
  return false;
}

/** Whether structured per-request SSE logging is currently enabled. */
function isSseDebugEnabled(): boolean {
  return process.env.VSKILL_DEBUG_SSE === "1";
}

/**
 * Proxy a single HTTP request from the studio to the platform.
 * SSE-safe: streams the upstream body chunks directly to the response so
 * `text/event-stream` connections stay open.
 *
 * Resolves once the proxy pipeline finishes (either side ended) — callers
 * can `await` to know the response has been written, but typical use is
 * fire-and-forget within an HTTP server handler.
 *
 * SSE diagnostic logging (0838 / AC-US1-02):
 *   When `VSKILL_DEBUG_SSE=1`, every `text/event-stream`-shaped request emits
 *     proxy.sse.start{requestId, upstreamUrl}  on dispatch
 *     proxy.sse.end{requestId, status, durationMs}  on completion
 *   AND the response gets `X-Request-Id: <requestId>` so client-side debug
 *   logs (in useSkillUpdates) can be correlated with server-side logs.
 *   When the flag is unset (or "0"), no per-request logs and no header
 *   injection — preserves the production hot path.
 */
export function proxyToPlatform(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  baseUrl: string = getPlatformBaseUrl(),
): Promise<void> {
  return new Promise((resolve) => {
    const target = new URL(req.url ?? "/", baseUrl);
    const transport = target.protocol === "https:" ? https : http;
    const debugEnabled = isSseDebugEnabled();
    // Pre-allocate requestId only when debug is enabled — avoids unnecessary
    // crypto work on the production hot path.
    const requestId = debugEnabled ? randomUUID() : null;
    const startTime = debugEnabled ? Date.now() : 0;
    // Path-based heuristic for SSE detection (cheap + sufficient for /stream).
    let isSseRequest = debugEnabled && target.pathname.endsWith("/stream");
    let logEndEmitted = false;
    // Captured for client-disconnect cleanup. Once headers are received the
    // active socket is held by upstreamRes (an SSE stream may keep it open
    // for hours); destroying upstreamReq alone is not enough on Node's https
    // agent in all cases.
    let upstreamRes: http.IncomingMessage | null = null;

    function emitEndLog(status: number): void {
      if (!debugEnabled || !isSseRequest || logEndEmitted || !requestId) return;
      logEndEmitted = true;
      const durationMs = Date.now() - startTime;
      console.log("proxy.sse.end", { requestId, status, durationMs });
    }

    const upstreamReq = transport.request(
      {
        protocol: target.protocol,
        hostname: target.hostname,
        port: target.port || (target.protocol === "https:" ? 443 : 80),
        path: `${target.pathname}${target.search}`,
        method: req.method,
        headers: pickHeadersForUpstream(req.headers, { path: target.pathname }),
      },
      (incoming) => {
        upstreamRes = incoming;
        const status = incoming.statusCode ?? 502;
        const headers = pickHeadersForDownstream(incoming.headers);
        // Promote to SSE if upstream content-type confirms it (covers future
        // SSE endpoints without a `/stream` suffix).
        if (debugEnabled && !isSseRequest) {
          const ct = incoming.headers["content-type"];
          isSseRequest = isSsePathOrResponse(
            target.pathname,
            Array.isArray(ct) ? ct[0] : ct,
          );
        }
        // Inject X-Request-Id so studio debug logs can correlate to server.
        if (debugEnabled && isSseRequest && requestId) {
          headers["x-request-id"] = requestId;
          // Emit the start log only once we know this IS an SSE request.
          console.log("proxy.sse.start", {
            requestId,
            upstreamUrl: `${target.origin}${target.pathname}${target.search}`,
          });
        }
        try {
          res.writeHead(status, headers);
        } catch {
          // Headers already sent — fall through to pipe; `res.write` will
          // continue on the existing stream.
        }
        incoming.on("end", () => {
          emitEndLog(status);
          resolve();
        });
        incoming.on("error", () => {
          emitEndLog(status);
          if (!res.writableEnded) res.end();
          resolve();
        });
        incoming.pipe(res);
      },
    );

    upstreamReq.on("error", (err) => {
      if (!res.headersSent) {
        res.writeHead(502, { "content-type": "application/json" });
      }
      if (!res.writableEnded) {
        res.end(
          JSON.stringify({
            error: "platform_unreachable",
            message: `vskill-platform proxy failed: ${err.message}`,
            target: target.origin,
          }),
        );
      }
      emitEndLog(502);
      resolve();
    });

    // Close upstream cleanly when the client disconnects (esp. EventSource
    // unmounts) so we don't leak sockets on long-lived SSE streams.
    // We tear down BOTH the request and the response — the response holds
    // the socket once headers have been received.
    res.on("close", () => {
      try { upstreamReq.destroy(); } catch { /* noop */ }
      try { upstreamRes?.destroy(); } catch { /* noop */ }
      emitEndLog(upstreamRes?.statusCode ?? 0);
    });

    // Forward the request body (if any) — req is a readable stream.
    // Contract: callers MUST NOT pre-read the body before reaching this
    // proxy, otherwise pipe sends nothing. No middleware in eval-server
    // currently does, but new ones must respect this.
    req.pipe(upstreamReq);
  });
}

// ---------------------------------------------------------------------------
// submitSkillUpdateEvent — push a SkillUpdateEvent to the platform's internal
// publish queue (POST /api/v1/internal/skills/publish). Used by the studio
// after apply-improvement / create-update so the platform UpdateHub fans out
// the new version to SSE subscribers without waiting for the 10-min scanner.
//
// Requires INTERNAL_BROADCAST_KEY env on both sides (eval-server + platform).
// In production the studio doesn't carry the key, so this degrades to a no-op
// returning { submitted: false, reason: "no_internal_key" } — the caller logs
// a hint that the user should `git push` so the scanner picks the change up.
//
// The publish endpoint (vskill-platform/src/app/api/v1/internal/skills/publish)
// validates `skillId` (UUID), `version`, `gitSha`, `publishedAt`, `eventId`.
// For local-dev with INTERNAL_BROADCAST_KEY set, callers can pass any string
// skillId (e.g. "<plugin>/<skill>") — the DO accepts the event but SSE filters
// based on UUID won't match. That's fine for the integration test path.
// ---------------------------------------------------------------------------

export interface SubmitSkillUpdateEventInput {
  skillId: string;
  version: string;
  gitSha?: string;
  publishedAt?: string;
  diffSummary?: string;
}

export type SubmitSkillUpdateEventResult =
  | { submitted: true; status: number; eventId: string }
  | { submitted: false; reason: "no_internal_key" | "platform_error"; status?: number; message?: string };

export async function submitSkillUpdateEvent(
  input: SubmitSkillUpdateEventInput,
  opts: { baseUrl?: string; internalKey?: string; fetchImpl?: typeof fetch } = {},
): Promise<SubmitSkillUpdateEventResult> {
  const internalKey = opts.internalKey ?? process.env.INTERNAL_BROADCAST_KEY;
  if (!internalKey) {
    return { submitted: false, reason: "no_internal_key" };
  }

  const baseUrl = opts.baseUrl ?? getPlatformBaseUrl();
  const fetchImpl = opts.fetchImpl ?? fetch;
  const eventId = randomUUID();
  const event = {
    type: "skill.updated" as const,
    eventId,
    skillId: input.skillId,
    version: input.version,
    gitSha: input.gitSha ?? `local-${Date.now().toString(16)}`,
    publishedAt: input.publishedAt ?? new Date().toISOString(),
    ...(input.diffSummary ? { diffSummary: input.diffSummary } : {}),
  };

  try {
    const resp = await fetchImpl(`${baseUrl}/api/v1/internal/skills/publish`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Internal-Key": internalKey,
      },
      body: JSON.stringify(event),
    });
    if (resp.ok) {
      return { submitted: true, status: resp.status, eventId };
    }
    const text = await resp.text().catch(() => "");
    return {
      submitted: false,
      reason: "platform_error",
      status: resp.status,
      message: text || resp.statusText,
    };
  } catch (err) {
    return {
      submitted: false,
      reason: "platform_error",
      message: (err as Error).message,
    };
  }
}
