// SPEC: PJ2026-01060509 出站诊断 draft-2026-06-26-p8-egress-job-friction.
// Provider-side egress TCP proxy with lifecycle logs for tunnel diagnosis.
import { createServer, type Server, type Socket } from "node:net";
import type {
  CoreEgressTcpCloseMessage,
  CoreEgressTcpDataMessage,
  CoreEgressTcpOpenedMessage,
  JsonValue,
  ProviderEgressTcpCloseMessage,
  ProviderEgressTcpDataMessage,
  ProviderEgressTcpOpenMessage,
} from "../../shared/src/index";

type Logger = (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue) => void;

type EgressToCoreMessage = ProviderEgressTcpOpenMessage | ProviderEgressTcpDataMessage | ProviderEgressTcpCloseMessage;
type EgressFromCoreMessage = CoreEgressTcpOpenedMessage | CoreEgressTcpDataMessage | CoreEgressTcpCloseMessage;

interface ProviderEgressProxyOptions {
  providerId: string;
  listenHost: string;
  listenPort: number;
  openTimeoutMs: number;
  idleTimeoutMs: number;
  maxTunnelAgeMs: number;
  staleTunnelIdleMs: number;
  maxPendingBytes: number;
  sendToCore: (message: EgressToCoreMessage) => boolean;
  isCoreConnected: () => boolean;
  logger: Logger;
  onStatusChanged?: () => void;
}

interface Tunnel {
  id: string;
  client: Socket;
  method: string;
  targetPathSummary: string | null;
  opened: boolean;
  pending: Buffer[];
  pendingBytes: number;
  closed: boolean;
  createdAt: number;
  lastActivityAt: number;
  targetHost: string;
  targetPort: number;
  bytesFromClient: number;
  bytesToClient: number;
  openTimer: ReturnType<typeof setTimeout> | null;
  idleTimer: ReturnType<typeof setTimeout> | null;
  maxAgeTimer: ReturnType<typeof setTimeout> | null;
}

interface ClosedTunnelSummary {
  [key: string]: JsonValue;
  connectionId: string;
  method: string;
  targetHost: string;
  targetPort: number;
  targetPathSummary: string | null;
  opened: boolean;
  ageMs: number;
  idleMs: number;
  closedAt: string;
  failureKind: string;
  closeReason: string | null;
  bytesFromClient: number;
  bytesToClient: number;
}

export interface ProviderEgressProxyHandle {
  status: () => Record<string, JsonValue>;
  handleCoreMessage: (message: EgressFromCoreMessage) => boolean;
  closeAll: (error?: string) => void;
  close: () => void;
}

function nowIso(): string {
  return new Date().toISOString();
}

function safeConnectionId(): string {
  return `egress_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}

function parseConnectTarget(target: string): { host: string; port: number } | null {
  const trimmed = target.trim();
  if (trimmed.startsWith("[")) {
    const end = trimmed.indexOf("]");
    if (end <= 1 || trimmed[end + 1] !== ":") return null;
    const port = Number(trimmed.slice(end + 2));
    return Number.isInteger(port) ? { host: trimmed.slice(1, end), port } : null;
  }
  const index = trimmed.lastIndexOf(":");
  if (index <= 0) return null;
  const port = Number(trimmed.slice(index + 1));
  return Number.isInteger(port) ? { host: trimmed.slice(0, index), port } : null;
}

function parseHttpProxyRequest(headerText: string): { method: string; target: string; version: string } | null {
  const line = headerText.split(/\r?\n/u)[0] ?? "";
  const match = /^([A-Z]+)\s+(\S+)\s+(HTTP\/\d(?:\.\d)?)$/u.exec(line);
  if (match === null) return null;
  return { method: match[1] ?? "", target: match[2] ?? "", version: match[3] ?? "HTTP/1.1" };
}

function summarizeHttpTargetPath(url: URL): string {
  const queryKeys = Array.from(new Set(Array.from(url.searchParams.keys()).filter(Boolean))).sort();
  if (queryKeys.length === 0) return url.pathname || "/";
  return `${url.pathname || "/"}?${queryKeys.slice(0, 8).join(",")}${queryKeys.length > 8 ? ",..." : ""}`;
}

function httpRequestTarget(rawTarget: string): { host: string; port: number; rewrittenTarget: string; pathSummary: string } | null {
  try {
    const url = new URL(rawTarget);
    if (url.protocol !== "http:") return null;
    return {
      host: url.hostname,
      port: url.port.length > 0 ? Number(url.port) : 80,
      rewrittenTarget: `${url.pathname}${url.search}`,
      pathSummary: summarizeHttpTargetPath(url),
    };
  } catch {
    return null;
  }
}

function rewriteAbsoluteHttpRequest(header: Buffer, rewrittenTarget: string): Buffer {
  const text = header.toString("latin1");
  const lines = text.split(/\r\n/u);
  const first = lines[0] ?? "";
  const match = /^([A-Z]+)\s+\S+\s+(HTTP\/\d(?:\.\d)?)$/u.exec(first);
  if (match !== null) lines[0] = `${match[1]} ${rewrittenTarget} ${match[2]}`;
  return Buffer.from(lines.join("\r\n"), "latin1");
}

function response(status: string, body: string, contentType = "text/plain; charset=utf-8"): string {
  return [
    `HTTP/1.1 ${status}`,
    `Content-Type: ${contentType}`,
    "Cache-Control: no-store",
    "Connection: close",
    `Content-Length: ${Buffer.byteLength(body)}`,
    "",
    body,
  ].join("\r\n");
}

function jsonResponse(value: JsonValue): string {
  return response("200 OK", `${JSON.stringify(value)}\n`, "application/json; charset=utf-8");
}

function proxyUrlFor(host: string, port: number): string {
  return `http://${host}:${port}`;
}

function egressFailureKind(error: string | undefined): string {
  if (error === undefined || error.length === 0) return "normal-close";
  const normalized = error.toLowerCase();
  if (normalized.includes("open timeout")) return "open-timeout";
  if (normalized.includes("idle timeout")) return "idle-timeout";
  if (normalized.includes("max tunnel age")) return "max-age-exceeded";
  if (normalized.includes("pending buffer")) return "pending-buffer-exceeded";
  if (normalized.includes("not connected")) return "core-channel-not-connected";
  if (normalized.includes("timeout")) return "timeout";
  if (normalized.includes("closed")) return "closed";
  return "remote-or-socket-error";
}

function tunnelState(tunnel: Tunnel, now: number, staleTunnelIdleMs: number): "pending" | "open" | "stale" {
  if (!tunnel.opened) return "pending";
  return now - tunnel.lastActivityAt >= staleTunnelIdleMs ? "stale" : "open";
}

export function startProviderEgressProxy(options: ProviderEgressProxyOptions): ProviderEgressProxyHandle {
  const proxyUrl = proxyUrlFor(options.listenHost, options.listenPort);
  const tunnels = new Map<string, Tunnel>();
  const recentClosedTunnels: ClosedTunnelSummary[] = [];
  let closed = false;

  const send = (message: EgressToCoreMessage): boolean => options.sendToCore(message);

  const status = (): Record<string, JsonValue> => {
    const now = Date.now();
    const tunnelList = Array.from(tunnels.values());
    const ages = tunnelList.map((tunnel) => now - tunnel.createdAt);
    const details = tunnelList
      .sort((left, right) => left.createdAt - right.createdAt)
      .slice(0, 12)
      .map((tunnel) => {
        const idleMs = now - tunnel.lastActivityAt;
        return {
          connectionId: tunnel.id,
          method: tunnel.method,
          targetHost: tunnel.targetHost,
          targetPort: tunnel.targetPort,
          targetPathSummary: tunnel.targetPathSummary,
          state: tunnelState(tunnel, now, options.staleTunnelIdleMs),
          opened: tunnel.opened,
          ageMs: now - tunnel.createdAt,
          idleMs,
          pendingBytes: tunnel.pendingBytes,
          bytesFromClient: tunnel.bytesFromClient,
          bytesToClient: tunnel.bytesToClient,
        };
      });
    return {
      enabled: true,
      providerId: options.providerId,
      connected: options.isCoreConnected(),
      proxyUrl,
      listenHost: options.listenHost,
      listenPort: options.listenPort,
      activeTunnels: tunnels.size,
      pendingTunnels: tunnelList.filter((tunnel) => !tunnel.opened).length,
      openedTunnels: tunnelList.filter((tunnel) => tunnel.opened).length,
      staleTunnels: tunnelList.filter((tunnel) => tunnelState(tunnel, now, options.staleTunnelIdleMs) === "stale").length,
      oldestTunnelAgeMs: ages.length > 0 ? Math.max(...ages) : 0,
      openTimeoutMs: options.openTimeoutMs,
      idleTimeoutMs: options.idleTimeoutMs,
      maxTunnelAgeMs: options.maxTunnelAgeMs,
      staleTunnelIdleMs: options.staleTunnelIdleMs,
      maxPendingBytes: options.maxPendingBytes,
      activeTunnelDetails: details,
      activeTunnelDetailLimit: 12,
      activeTunnelDetailsTruncated: tunnelList.length > details.length,
      recentClosedTunnels,
      channel: "provider-gateway",
    };
  };

  const clearTunnelTimers = (tunnel: Tunnel): void => {
    if (tunnel.openTimer !== null) clearTimeout(tunnel.openTimer);
    if (tunnel.idleTimer !== null) clearTimeout(tunnel.idleTimer);
    if (tunnel.maxAgeTimer !== null) clearTimeout(tunnel.maxAgeTimer);
    tunnel.openTimer = null;
    tunnel.idleTimer = null;
    tunnel.maxAgeTimer = null;
  };

  const destroyTunnel = (tunnel: Tunnel, notifyCore: boolean, error?: string): void => {
    tunnels.delete(tunnel.id);
    tunnel.closed = true;
    const closedAt = nowIso();
    const ageMs = Date.now() - tunnel.createdAt;
    const idleMs = Date.now() - tunnel.lastActivityAt;
    const pendingBytes = tunnel.pendingBytes;
    clearTunnelTimers(tunnel);
    tunnel.pending.splice(0);
    tunnel.pendingBytes = 0;
    if (!tunnel.client.destroyed) tunnel.client.destroy();
    if (notifyCore) send({ type: "egress_tcp_close", providerId: options.providerId, connectionId: tunnel.id, at: nowIso() });
    const failureKind = egressFailureKind(error);
    recentClosedTunnels.unshift({
      connectionId: tunnel.id,
      method: tunnel.method,
      targetHost: tunnel.targetHost,
      targetPort: tunnel.targetPort,
      targetPathSummary: tunnel.targetPathSummary,
      opened: tunnel.opened,
      ageMs,
      idleMs,
      closedAt,
      failureKind,
      closeReason: error ?? null,
      bytesFromClient: tunnel.bytesFromClient,
      bytesToClient: tunnel.bytesToClient,
    });
    recentClosedTunnels.splice(16);
    const event = {
      providerId: options.providerId,
      connectionId: tunnel.id,
      method: tunnel.method,
      targetHost: tunnel.targetHost,
      targetPort: tunnel.targetPort,
      targetPathSummary: tunnel.targetPathSummary,
      opened: tunnel.opened,
      ageMs,
      idleMs,
      pendingBytes,
      bytesFromClient: tunnel.bytesFromClient,
      bytesToClient: tunnel.bytesToClient,
      failureKind,
      ...(error === undefined ? {} : { error }),
    };
    options.logger(error === undefined ? "info" : "warn", "egress_proxy_tunnel_closed", event);
    options.onStatusChanged?.();
  };

  const closeTunnel = (id: string, error?: string): void => {
    const tunnel = tunnels.get(id);
    if (tunnel === undefined) return;
    destroyTunnel(tunnel, true, error);
  };

  const refreshTunnelIdle = (tunnel: Tunnel): void => {
    if (tunnel.closed) return;
    tunnel.lastActivityAt = Date.now();
    if (tunnel.idleTimer !== null) clearTimeout(tunnel.idleTimer);
    tunnel.idleTimer = setTimeout(() => closeTunnel(tunnel.id, "egress proxy idle timeout"), options.idleTimeoutMs);
  };

  const queuePendingChunk = (tunnel: Tunnel, chunk: Buffer): boolean => {
    if (tunnel.closed) return false;
    tunnel.pending.push(chunk);
    tunnel.pendingBytes += chunk.byteLength;
    tunnel.bytesFromClient += chunk.byteLength;
    refreshTunnelIdle(tunnel);
    if (tunnel.pendingBytes <= options.maxPendingBytes) return true;
    closeTunnel(tunnel.id, "egress proxy pending buffer exceeded");
    return false;
  };

  const handleCoreMessage = (message: EgressFromCoreMessage): boolean => {
    const tunnel = tunnels.get(message.connectionId);
    if (message.type === "egress_tcp_opened") {
      if (tunnel === undefined || tunnel.closed) return true;
      tunnel.opened = true;
      if (tunnel.openTimer !== null) {
        clearTimeout(tunnel.openTimer);
        tunnel.openTimer = null;
      }
      refreshTunnelIdle(tunnel);
      options.logger("info", "egress_proxy_tunnel_opened", {
        providerId: options.providerId,
        connectionId: tunnel.id,
        method: tunnel.method,
        targetHost: tunnel.targetHost,
        targetPort: tunnel.targetPort,
        targetPathSummary: tunnel.targetPathSummary,
        ageMs: Date.now() - tunnel.createdAt,
        pendingBytes: tunnel.pendingBytes,
      });
      if (tunnel.method === "CONNECT") {
        tunnel.client.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: UniDesk-ProviderGateway\r\n\r\n");
      }
      for (const chunk of tunnel.pending.splice(0)) {
        send({ type: "egress_tcp_data", providerId: options.providerId, connectionId: tunnel.id, data: chunk.toString("base64"), encoding: "base64", at: nowIso() });
      }
      tunnel.pendingBytes = 0;
      options.onStatusChanged?.();
      return true;
    }
    if (message.type === "egress_tcp_data") {
      if (tunnel === undefined || tunnel.client.destroyed) return true;
      refreshTunnelIdle(tunnel);
      const payload = Buffer.from(message.data, message.encoding === "base64" ? "base64" : "utf8");
      tunnel.bytesToClient += payload.byteLength;
      tunnel.client.write(payload);
      return true;
    }
    if (message.type === "egress_tcp_close") {
      if (tunnel !== undefined) {
        destroyTunnel(tunnel, false, message.error);
      }
      if (message.error !== undefined && message.error.length > 0) {
        options.logger("warn", "egress_proxy_remote_close", {
          providerId: options.providerId,
          connectionId: message.connectionId,
          targetHost: tunnel?.targetHost ?? null,
          targetPort: tunnel?.targetPort ?? null,
          failureKind: egressFailureKind(message.error),
          error: message.error,
        });
      }
      return true;
    }
    return false;
  };

  const server: Server = createServer((client) => {
    let header = Buffer.alloc(0);
    let settled = false;
    const fail = (statusCode: string, text: string): void => {
      if (!client.destroyed) client.end(response(statusCode, text));
    };
    client.on("data", (chunk) => {
      const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
      if (settled) return;
      header = Buffer.concat([header, buffer]);
      const headerEnd = header.indexOf("\r\n\r\n");
      if (headerEnd < 0) {
        if (header.length > 64 * 1024) {
          settled = true;
          fail("431 Request Header Fields Too Large", "proxy request header too large\n");
        }
        return;
      }
      settled = true;
      const head = header.subarray(0, headerEnd + 4);
      const rest = header.subarray(headerEnd + 4);
      const parsed = parseHttpProxyRequest(head.toString("latin1"));
      if (parsed === null) {
        fail("400 Bad Request", "invalid proxy request\n");
        return;
      }
      if (parsed.method === "GET" && (parsed.target === "/health" || parsed.target === "/__unidesk/egress-proxy/health")) {
        client.end(jsonResponse({ ok: true, service: "provider-gateway-egress-proxy", ...status() }));
        return;
      }
      if (!options.isCoreConnected()) {
        fail("503 Service Unavailable", "provider-gateway core channel is not connected\n");
        return;
      }
      const id = safeConnectionId();
      let firstPayload: Buffer | null = null;
      let target: { host: string; port: number } | null = null;
      let targetPathSummary: string | null = null;
      if (parsed.method === "CONNECT") {
        target = parseConnectTarget(parsed.target);
        firstPayload = rest.length > 0 ? rest : null;
      } else {
        const httpTarget = httpRequestTarget(parsed.target);
        if (httpTarget !== null) {
          target = { host: httpTarget.host, port: httpTarget.port };
          targetPathSummary = httpTarget.pathSummary;
          firstPayload = Buffer.concat([rewriteAbsoluteHttpRequest(head, httpTarget.rewrittenTarget), rest]);
        }
      }
      if (target === null || target.port <= 0 || target.port > 65_535) {
        fail("400 Bad Request", "unsupported proxy target\n");
        return;
      }
      const createdAt = Date.now();
      const tunnel: Tunnel = {
        id,
        client,
        method: parsed.method,
        targetPathSummary,
        opened: false,
        pending: [],
        pendingBytes: 0,
        closed: false,
        createdAt,
        lastActivityAt: createdAt,
        targetHost: target.host,
        targetPort: target.port,
        bytesFromClient: 0,
        bytesToClient: 0,
        openTimer: null,
        idleTimer: null,
        maxAgeTimer: null,
      };
      tunnels.set(id, tunnel);
      options.logger("info", "egress_proxy_tunnel_open_request", {
        providerId: options.providerId,
        connectionId: id,
        method: parsed.method,
        targetHost: target.host,
        targetPort: target.port,
        targetPathSummary,
        firstPayloadBytes: firstPayload === null ? 0 : firstPayload.byteLength,
      });
      options.onStatusChanged?.();
      client.on("data", (nextChunk) => {
        const nextBuffer = Buffer.isBuffer(nextChunk) ? nextChunk : Buffer.from(nextChunk);
        if (!tunnel.opened) {
          queuePendingChunk(tunnel, nextBuffer);
          return;
        }
        refreshTunnelIdle(tunnel);
        tunnel.bytesFromClient += nextBuffer.byteLength;
        send({ type: "egress_tcp_data", providerId: options.providerId, connectionId: id, data: nextBuffer.toString("base64"), encoding: "base64", at: nowIso() });
      });
      client.on("close", () => closeTunnel(id));
      client.on("error", (error) => closeTunnel(id, error.message));
      const opened = send({ type: "egress_tcp_open", providerId: options.providerId, connectionId: id, host: target.host, port: target.port, at: nowIso() });
      if (!opened) {
        tunnels.delete(id);
        tunnel.closed = true;
        clearTunnelTimers(tunnel);
        options.logger("warn", "egress_proxy_tunnel_open_not_sent", {
          providerId: options.providerId,
          connectionId: id,
          method: parsed.method,
          targetHost: target.host,
          targetPort: target.port,
          targetPathSummary,
          failureKind: "core-channel-not-connected",
        });
        fail("503 Service Unavailable", "provider-gateway core channel is not connected\n");
        options.onStatusChanged?.();
        return;
      }
      tunnel.openTimer = setTimeout(() => closeTunnel(id, "egress proxy open timeout"), options.openTimeoutMs);
      tunnel.maxAgeTimer = setTimeout(() => closeTunnel(id, "egress proxy max tunnel age exceeded"), options.maxTunnelAgeMs);
      refreshTunnelIdle(tunnel);
      if (firstPayload !== null) queuePendingChunk(tunnel, firstPayload);
    });
    client.on("error", () => undefined);
  });

  server.listen(options.listenPort, options.listenHost, () => {
    options.logger("info", "egress_proxy_listening", status());
  });
  server.on("error", (error) => {
    options.logger("error", "egress_proxy_listen_failed", { proxyUrl, error: error.message });
  });

  return {
    status,
    handleCoreMessage,
    closeAll: (error?: string) => {
      for (const id of Array.from(tunnels.keys())) closeTunnel(id, error);
    },
    close: () => {
      closed = true;
      for (const id of Array.from(tunnels.keys())) closeTunnel(id, "egress proxy stopping");
      server.close();
    },
  };
}
