import { randomUUID } from "node:crypto";
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
import {
  clearDeviceAuthToken,
  loadDeviceAuthToken,
  storeDeviceAuthToken,
} from "../infra/device-auth-store.js";
import type { DeviceIdentity } from "../infra/device-identity.js";
import {
  loadOrCreateDeviceIdentity,
  publicKeyRawBase64UrlFromPem,
  signDevicePayload,
} from "../infra/device-identity.js";
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js";
import {
  normalizeLowercaseStringOrEmpty,
  normalizeOptionalString,
} from "../shared/string-coerce.js";
import {
  GATEWAY_CLIENT_MODES,
  GATEWAY_CLIENT_NAMES,
  type GatewayClientMode,
  type GatewayClientName,
} from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { resolveConnectChallengeTimeoutMs } from "./handshake-timeouts.js";
import { isLoopbackHost, isSecureWebSocketUrl } from "./net.js";
import {
  ConnectErrorDetailCodes,
  readConnectErrorDetailCode,
  readConnectErrorRecoveryAdvice,
  type ConnectErrorRecoveryAdvice,
} from "./protocol/connect-error-details.js";
import {
  type ConnectParams,
  type EventFrame,
  type HelloOk,
  PROTOCOL_VERSION,
  type RequestFrame,
  validateEventFrame,
  validateRequestFrame,
  validateResponseFrame,
} from "./protocol/index.js";

type Pending = {
  resolve: (value: unknown) => void;
  reject: (err: unknown) => void;
  expectFinal: boolean;
  timeout: NodeJS.Timeout | null;
};

type GatewayClientErrorShape = {
  code?: string;
  message?: string;
  details?: unknown;
};

type SelectedConnectAuth = {
  authToken?: string;
  authBootstrapToken?: string;
  authDeviceToken?: string;
  authPassword?: string;
  signatureToken?: string;
  resolvedDeviceToken?: string;
  storedToken?: string;
  storedScopes?: string[];
  usingStoredDeviceToken?: boolean;
};

type StoredDeviceAuth = {
  token?: string;
  scopes?: string[];
};

type FingerprintCheckingClientOptions = Omit<ClientOptions, "checkServerIdentity"> & {
  checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined;
};

class GatewayClientRequestError extends Error {
  readonly gatewayCode: string;
  readonly details?: unknown;

  constructor(error: GatewayClientErrorShape) {
    super(error.message ?? "gateway request failed");
    this.name = "GatewayClientRequestError";
    this.gatewayCode = error.code ?? "UNAVAILABLE";
    this.details = error.details;
  }
}

export type GatewayClientOptions = {
  url?: string; // ws://127.0.0.1:18789
  connectChallengeTimeoutMs?: number;
  /** @deprecated Use connectChallengeTimeoutMs. */
  connectDelayMs?: number;
  tickWatchMinIntervalMs?: number;
  requestTimeoutMs?: number;
  token?: string;
  bootstrapToken?: string;
  deviceToken?: string;
  password?: string;
  instanceId?: string;
  clientName?: GatewayClientName;
  clientDisplayName?: string;
  clientVersion?: string;
  platform?: string;
  deviceFamily?: string;
  mode?: GatewayClientMode;
  role?: string;
  scopes?: string[];
  caps?: string[];
  commands?: string[];
  permissions?: Record<string, boolean>;
  pathEnv?: string;
  deviceIdentity?: DeviceIdentity | null;
  minProtocol?: number;
  maxProtocol?: number;
  tlsFingerprint?: string;
  onEvent?: (evt: EventFrame) => void;
  onHelloOk?: (hello: HelloOk) => void;
  onConnectError?: (err: Error) => void;
  onClose?: (code: number, reason: string) => void;
  onGap?: (info: { expected: number; received: number }) => void;
};

export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
  1000: "normal closure",
  1006: "abnormal closure (no close frame)",
  1008: "policy violation",
  1012: "service restart",
};

export function describeGatewayCloseCode(code: number): string | undefined {
  return GATEWAY_CLOSE_CODE_HINTS[code];
}

function readConnectChallengeTimeoutOverride(
  opts: Pick<GatewayClientOptions, "connectChallengeTimeoutMs" | "connectDelayMs">,
): number | undefined {
  if (
    typeof opts.connectChallengeTimeoutMs === "number" &&
    Number.isFinite(opts.connectChallengeTimeoutMs)
  ) {
    return opts.connectChallengeTimeoutMs;
  }
  if (typeof opts.connectDelayMs === "number" && Number.isFinite(opts.connectDelayMs)) {
    return opts.connectDelayMs;
  }
  return undefined;
}

function isGatewayClientStoppedError(err: unknown): boolean {
  const message = err instanceof Error ? err.message : String(err);
  return message === "gateway client stopped" || message === "Error: gateway client stopped";
}

export function resolveGatewayClientConnectChallengeTimeoutMs(
  opts: Pick<GatewayClientOptions, "connectChallengeTimeoutMs" | "connectDelayMs">,
): number {
  return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts));
}

const FORCE_STOP_TERMINATE_GRACE_MS = 250;
const STOP_AND_WAIT_TIMEOUT_MS = 1_000;

type PendingStop = {
  ws: WebSocket;
  promise: Promise<void>;
  resolve: () => void;
};

export class GatewayClient {
  private ws: WebSocket | null = null;
  private opts: GatewayClientOptions;
  private pending = new Map<string, Pending>();
  private backoffMs = 1000;
  private closed = false;
  private lastSeq: number | null = null;
  private connectNonce: string | null = null;
  private connectSent = false;
  private connectTimer: NodeJS.Timeout | null = null;
  private pendingDeviceTokenRetry = false;
  private deviceTokenRetryBudgetUsed = false;
  private pendingConnectErrorDetailCode: string | null = null;
  // Track last tick to detect silent stalls.
  private lastTick: number | null = null;
  private tickIntervalMs = 30_000;
  private tickTimer: NodeJS.Timeout | null = null;
  private readonly requestTimeoutMs: number;
  private pendingStop: PendingStop | null = null;
  private socketOpened = false;

  constructor(opts: GatewayClientOptions) {
    this.opts = {
      ...opts,
      deviceIdentity:
        opts.deviceIdentity === null
          ? undefined
          : (opts.deviceIdentity ?? loadOrCreateDeviceIdentity()),
    };
    this.requestTimeoutMs =
      typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs)
        ? Math.max(1, Math.min(Math.floor(opts.requestTimeoutMs), 2_147_483_647))
        : 30_000;
  }

  start() {
    if (this.closed) {
      return;
    }
    this.clearConnectChallengeTimeout();
    this.connectNonce = null;
    this.connectSent = false;
    const url = this.opts.url ?? "ws://127.0.0.1:18789";
    if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
      this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url"));
      return;
    }

    const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
    // Security check: block ALL plaintext ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
    // This protects both credentials AND chat/conversation data from MITM attacks.
    // Device tokens may be loaded later in sendConnect(), so we block regardless of hasCredentials.
    if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
      // Safe hostname extraction - avoid throwing on malformed URLs in error path
      let displayHost = url;
      try {
        displayHost = new URL(url).hostname || url;
      } catch {
        // Use raw URL if parsing fails
      }
      const error = new Error(
        `SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
          "Both credentials and chat data would be exposed to network interception. " +
          "Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
          "(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
          (allowPrivateWs
            ? ""
            : "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
          "Run `openclaw doctor --fix` for guidance.",
      );
      this.opts.onConnectError?.(error);
      return;
    }
    // Allow node screen snapshots and other large responses.
    const wsOptions: FingerprintCheckingClientOptions = {
      maxPayload: 25 * 1024 * 1024,
    };
    if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
      wsOptions.rejectUnauthorized = false;
      wsOptions.checkServerIdentity = (_host: string, cert: CertMeta) => {
        const fingerprintValue =
          typeof cert === "object" && cert && "fingerprint256" in cert
            ? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
            : "";
        const fingerprint = normalizeFingerprint(
          typeof fingerprintValue === "string" ? fingerprintValue : "",
        );
        const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
        if (!expected) {
          return undefined;
        }
        if (!fingerprint) {
          return new Error("Missing server TLS fingerprint");
        }
        if (fingerprint !== expected) {
          return new Error("Server TLS fingerprint mismatch");
        }
        return undefined;
      };
    }
    const ws = new WebSocket(url, wsOptions as ClientOptions);
    this.ws = ws;
    this.socketOpened = false;
    this.connectNonce = null;
    this.connectSent = false;
    this.clearConnectChallengeTimeout();

    ws.on("open", () => {
      this.socketOpened = true;
      if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
        const tlsError = this.validateTlsFingerprint();
        if (tlsError) {
          this.opts.onConnectError?.(tlsError);
          this.ws?.close(1008, tlsError.message);
          return;
        }
      }
      this.beginPreauthHandshake();
    });
    ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
    ws.on("close", (code, reason) => {
      const reasonText = rawDataToString(reason);
      const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
      this.pendingConnectErrorDetailCode = null;
      if (this.ws === ws) {
        this.ws = null;
      }
      this.socketOpened = false;
      this.resolvePendingStop(ws);
      // Clear persisted device auth state only when device-token auth was active.
      // Shared token/password failures can return the same close reason but should
      // not erase a valid cached device token.
      if (
        code === 1008 &&
        normalizeLowercaseStringOrEmpty(reasonText).includes("device token mismatch") &&
        !this.opts.token &&
        !this.opts.password &&
        this.opts.deviceIdentity
      ) {
        const deviceId = this.opts.deviceIdentity.deviceId;
        const role = this.opts.role ?? "operator";
        try {
          clearDeviceAuthToken({ deviceId, role });
          logDebug(`cleared stale device-auth token for device ${deviceId}`);
        } catch (err) {
          logDebug(
            `failed clearing stale device-auth token for device ${deviceId}: ${String(err)}`,
          );
        }
      }
      this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
      if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) {
        this.opts.onClose?.(code, reasonText);
        return;
      }
      this.scheduleReconnect();
      this.opts.onClose?.(code, reasonText);
    });
    ws.on("error", (err) => {
      logDebug(`gateway client error: ${String(err)}`);
      if (!this.connectSent) {
        this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
      }
    });
  }

  stop() {
    void this.beginStop();
  }

  async stopAndWait(opts?: { timeoutMs?: number }): Promise<void> {
    // Some callers need teardown ordering, not just "close requested". Wait for
    // the socket to close or the terminate fallback to fire.
    const stopPromise = this.beginStop();
    if (!stopPromise) {
      return;
    }
    const timeoutMs =
      typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
        ? Math.max(1, Math.floor(opts.timeoutMs))
        : STOP_AND_WAIT_TIMEOUT_MS;
    let timeout: NodeJS.Timeout | null = null;
    try {
      await Promise.race([
        stopPromise,
        new Promise<never>((_, reject) => {
          timeout = setTimeout(() => {
            reject(new Error(`gateway client stop timed out after ${timeoutMs}ms`));
          }, timeoutMs);
          timeout.unref?.();
        }),
      ]);
    } finally {
      if (timeout) {
        clearTimeout(timeout);
      }
    }
  }

  private beginStop(): Promise<void> | null {
    this.closed = true;
    this.pendingDeviceTokenRetry = false;
    this.deviceTokenRetryBudgetUsed = false;
    this.pendingConnectErrorDetailCode = null;
    if (this.tickTimer) {
      clearInterval(this.tickTimer);
      this.tickTimer = null;
    }
    this.clearConnectChallengeTimeout();
    if (this.pendingStop) {
      this.flushPendingErrors(new Error("gateway client stopped"));
      return this.pendingStop.promise;
    }
    const ws = this.ws;
    this.ws = null;
    if (ws) {
      const stopPromise = this.createPendingStop(ws);
      ws.close();
      const forceTerminateTimer = setTimeout(() => {
        try {
          ws.terminate();
        } catch {}
        this.resolvePendingStop(ws);
      }, FORCE_STOP_TERMINATE_GRACE_MS);
      forceTerminateTimer.unref?.();
      this.flushPendingErrors(new Error("gateway client stopped"));
      return stopPromise;
    }
    this.flushPendingErrors(new Error("gateway client stopped"));
    return null;
  }

  private createPendingStop(ws: WebSocket): Promise<void> {
    if (this.pendingStop?.ws === ws) {
      return this.pendingStop.promise;
    }
    let resolve!: () => void;
    const promise = new Promise<void>((res) => {
      resolve = res;
    });
    this.pendingStop = { ws, promise, resolve };
    return promise;
  }

  private resolvePendingStop(ws: WebSocket): void {
    if (this.pendingStop?.ws !== ws) {
      return;
    }
    const { resolve } = this.pendingStop;
    this.pendingStop = null;
    resolve();
  }

  private sendConnect() {
    if (this.connectSent) {
      return;
    }
    const nonce = normalizeOptionalString(this.connectNonce) ?? "";
    if (!nonce) {
      this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
      this.ws?.close(1008, "connect challenge missing nonce");
      return;
    }
    this.connectSent = true;
    this.clearConnectChallengeTimeout();
    const role = this.opts.role ?? "operator";
    const {
      authToken,
      authBootstrapToken,
      authDeviceToken,
      authPassword,
      signatureToken,
      resolvedDeviceToken,
      storedToken,
      storedScopes,
      usingStoredDeviceToken,
    } = this.selectConnectAuth(role);
    if (this.pendingDeviceTokenRetry && authDeviceToken) {
      this.pendingDeviceTokenRetry = false;
    }
    const auth =
      authToken || authBootstrapToken || authPassword || resolvedDeviceToken
        ? {
            token: authToken,
            bootstrapToken: authBootstrapToken,
            deviceToken: authDeviceToken ?? resolvedDeviceToken,
            password: authPassword,
          }
        : undefined;
    const signedAtMs = Date.now();
    const scopes = this.resolveConnectScopes({
      usingStoredDeviceToken,
      storedScopes,
    });
    const platform = this.opts.platform ?? process.platform;
    const device = (() => {
      if (!this.opts.deviceIdentity) {
        return undefined;
      }
      const payload = buildDeviceAuthPayloadV3({
        deviceId: this.opts.deviceIdentity.deviceId,
        clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
        clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
        role,
        scopes,
        signedAtMs,
        token: signatureToken ?? null,
        nonce,
        platform,
        deviceFamily: this.opts.deviceFamily,
      });
      const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
      return {
        id: this.opts.deviceIdentity.deviceId,
        publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
        signature,
        signedAt: signedAtMs,
        nonce,
      };
    })();
    const params: ConnectParams = {
      minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
      maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
      client: {
        id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
        displayName: this.opts.clientDisplayName,
        version: this.opts.clientVersion ?? VERSION,
        platform,
        deviceFamily: this.opts.deviceFamily,
        mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
        instanceId: this.opts.instanceId,
      },
      caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
      commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
      permissions:
        this.opts.permissions && typeof this.opts.permissions === "object"
          ? this.opts.permissions
          : undefined,
      pathEnv: this.opts.pathEnv,
      auth,
      role,
      scopes,
      device,
    };

    void this.request<HelloOk>("connect", params)
      .then((helloOk) => {
        this.pendingDeviceTokenRetry = false;
        this.deviceTokenRetryBudgetUsed = false;
        this.pendingConnectErrorDetailCode = null;
        const authInfo = helloOk?.auth;
        if (authInfo?.deviceToken && this.opts.deviceIdentity) {
          storeDeviceAuthToken({
            deviceId: this.opts.deviceIdentity.deviceId,
            role: authInfo.role ?? role,
            token: authInfo.deviceToken,
            scopes: authInfo.scopes ?? [],
          });
        }
        this.backoffMs = 1000;
        this.tickIntervalMs =
          typeof helloOk.policy?.tickIntervalMs === "number"
            ? helloOk.policy.tickIntervalMs
            : 30_000;
        this.lastTick = Date.now();
        this.startTickWatch();
        this.opts.onHelloOk?.(helloOk);
      })
      .catch((err) => {
        this.pendingConnectErrorDetailCode =
          err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null;
        const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({
          error: err,
          explicitGatewayToken: normalizeOptionalString(this.opts.token),
          resolvedDeviceToken,
          storedToken: storedToken ?? undefined,
        });
        if (shouldRetryWithDeviceToken) {
          this.pendingDeviceTokenRetry = true;
          this.deviceTokenRetryBudgetUsed = true;
          this.backoffMs = Math.min(this.backoffMs, 250);
        }
        this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
        const msg = `gateway connect failed: ${String(err)}`;
        if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(err)) {
          logDebug(msg);
        } else {
          logError(msg);
        }
        this.ws?.close(1008, "connect failed");
      });
  }

  private resolveConnectScopes(params: {
    usingStoredDeviceToken?: boolean;
    storedScopes?: string[];
  }): string[] {
    // Reuse cached scopes only when the client is reusing the cached device token.
    // Explicit device tokens should keep the caller-requested scope set.
    if (
      params.usingStoredDeviceToken &&
      Array.isArray(params.storedScopes) &&
      params.storedScopes.length > 0
    ) {
      return params.storedScopes;
    }
    return this.opts.scopes ?? ["operator.admin"];
  }

  private loadStoredDeviceAuth(role: string): StoredDeviceAuth | null {
    if (!this.opts.deviceIdentity) {
      return null;
    }
    const storedAuth = loadDeviceAuthToken({
      deviceId: this.opts.deviceIdentity.deviceId,
      role,
    });
    if (!storedAuth) {
      return null;
    }
    return {
      token: storedAuth.token,
      scopes: storedAuth.scopes,
    };
  }

  private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean {
    if (!detailCode) {
      return false;
    }
    if (
      detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
      detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
      detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
      detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
      detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
      detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
      detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
      detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
    ) {
      return true;
    }
    if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
      return false;
    }
    if (this.pendingDeviceTokenRetry) {
      return false;
    }
    // If the endpoint is not trusted for retry, mismatch is terminal until operator action.
    if (!this.isTrustedDeviceRetryEndpoint()) {
      return true;
    }
    // Pause mismatch reconnect loops once the one-shot device-token retry is consumed.
    return this.deviceTokenRetryBudgetUsed;
  }

  private shouldRetryWithStoredDeviceToken(params: {
    error: unknown;
    explicitGatewayToken?: string;
    storedToken?: string;
    resolvedDeviceToken?: string;
  }): boolean {
    if (this.deviceTokenRetryBudgetUsed) {
      return false;
    }
    if (params.resolvedDeviceToken) {
      return false;
    }
    if (!params.explicitGatewayToken || !params.storedToken) {
      return false;
    }
    if (!this.isTrustedDeviceRetryEndpoint()) {
      return false;
    }
    if (!(params.error instanceof GatewayClientRequestError)) {
      return false;
    }
    const detailCode = readConnectErrorDetailCode(params.error.details);
    const advice: ConnectErrorRecoveryAdvice = readConnectErrorRecoveryAdvice(params.error.details);
    const retryWithDeviceTokenRecommended =
      advice.recommendedNextStep === "retry_with_device_token";
    return (
      advice.canRetryWithDeviceToken === true ||
      retryWithDeviceTokenRecommended ||
      detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
    );
  }

  private isTrustedDeviceRetryEndpoint(): boolean {
    const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
    try {
      const parsed = new URL(rawUrl);
      const protocol =
        parsed.protocol === "https:"
          ? "wss:"
          : parsed.protocol === "http:"
            ? "ws:"
            : parsed.protocol;
      if (isLoopbackHost(parsed.hostname)) {
        return true;
      }
      return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim());
    } catch {
      return false;
    }
  }

  private selectConnectAuth(role: string): SelectedConnectAuth {
    const explicitGatewayToken = normalizeOptionalString(this.opts.token);
    const explicitBootstrapToken = normalizeOptionalString(this.opts.bootstrapToken);
    const explicitDeviceToken = normalizeOptionalString(this.opts.deviceToken);
    const authPassword = normalizeOptionalString(this.opts.password);
    const storedAuth = this.loadStoredDeviceAuth(role);
    const storedToken = storedAuth?.token ?? null;
    const storedScopes = storedAuth?.scopes;
    const shouldUseDeviceRetryToken =
      this.pendingDeviceTokenRetry &&
      !explicitDeviceToken &&
      Boolean(explicitGatewayToken) &&
      Boolean(storedToken) &&
      this.isTrustedDeviceRetryEndpoint();
    const resolvedDeviceToken =
      explicitDeviceToken ??
      (shouldUseDeviceRetryToken ||
      (!(explicitGatewayToken || authPassword) && (!explicitBootstrapToken || Boolean(storedToken)))
        ? (storedToken ?? undefined)
        : undefined);
    const reusingStoredDeviceToken =
      Boolean(resolvedDeviceToken) &&
      !explicitDeviceToken &&
      Boolean(storedToken) &&
      resolvedDeviceToken === storedToken;
    // Legacy compatibility: keep `auth.token` populated for device-token auth when
    // no explicit shared token is present.
    const authToken = explicitGatewayToken ?? resolvedDeviceToken;
    const authBootstrapToken =
      !explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined;
    return {
      authToken,
      authBootstrapToken,
      authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
      authPassword,
      signatureToken: authToken ?? authBootstrapToken ?? undefined,
      resolvedDeviceToken,
      storedToken: storedToken ?? undefined,
      storedScopes,
      usingStoredDeviceToken: reusingStoredDeviceToken,
    };
  }

  private handleMessage(raw: string) {
    try {
      const parsed = JSON.parse(raw);
      if (validateEventFrame(parsed)) {
        this.lastTick = Date.now();
        const evt = parsed;
        if (evt.event === "connect.challenge") {
          const payload = evt.payload as { nonce?: unknown } | undefined;
          const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
          if (!nonce || nonce.trim().length === 0) {
            this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
            this.ws?.close(1008, "connect challenge missing nonce");
            return;
          }
          this.connectNonce = nonce.trim();
          if (this.socketOpened) {
            this.sendConnect();
          }
          return;
        }
        const seq = typeof evt.seq === "number" ? evt.seq : null;
        if (seq !== null) {
          if (this.lastSeq !== null && seq > this.lastSeq + 1) {
            this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
          }
          this.lastSeq = seq;
        }
        if (evt.event === "tick") {
          this.lastTick = Date.now();
        }
        this.opts.onEvent?.(evt);
        return;
      }
      if (validateResponseFrame(parsed)) {
        this.lastTick = Date.now();
        const pending = this.pending.get(parsed.id);
        if (!pending) {
          return;
        }
        // If the payload is an ack with status accepted, keep waiting for final.
        const payload = parsed.payload as { status?: unknown } | undefined;
        const status = payload?.status;
        if (pending.expectFinal && status === "accepted") {
          return;
        }
        this.pending.delete(parsed.id);
        if (pending.timeout) {
          clearTimeout(pending.timeout);
        }
        if (parsed.ok) {
          pending.resolve(parsed.payload);
        } else {
          pending.reject(
            new GatewayClientRequestError({
              code: parsed.error?.code,
              message: parsed.error?.message ?? "unknown error",
              details: parsed.error?.details,
            }),
          );
        }
      }
    } catch (err) {
      logDebug(`gateway client parse error: ${String(err)}`);
    }
  }

  private beginPreauthHandshake() {
    if (this.connectSent) {
      return;
    }
    if (this.connectNonce && !this.connectSent) {
      this.armConnectChallengeTimeout();
      this.sendConnect();
      return;
    }
    this.armConnectChallengeTimeout();
  }

  private clearConnectChallengeTimeout() {
    if (this.connectTimer) {
      clearTimeout(this.connectTimer);
      this.connectTimer = null;
    }
  }

  private armConnectChallengeTimeout() {
    const connectChallengeTimeoutMs = resolveGatewayClientConnectChallengeTimeoutMs(this.opts);
    const armedAt = Date.now();
    this.clearConnectChallengeTimeout();
    this.connectTimer = setTimeout(() => {
      if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
        return;
      }
      const elapsedMs = Date.now() - armedAt;
      this.opts.onConnectError?.(
        new Error(
          `gateway connect challenge timeout (waited ${elapsedMs}ms, limit ${connectChallengeTimeoutMs}ms)`,
        ),
      );
      this.ws?.close(1008, "connect challenge timeout");
    }, connectChallengeTimeoutMs);
  }

  private scheduleReconnect() {
    if (this.closed) {
      return;
    }
    if (this.tickTimer) {
      clearInterval(this.tickTimer);
      this.tickTimer = null;
    }
    const delay = this.backoffMs;
    this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
    setTimeout(() => this.start(), delay).unref();
  }

  private flushPendingErrors(err: Error) {
    for (const [, p] of this.pending) {
      if (p.timeout) {
        clearTimeout(p.timeout);
      }
      p.reject(err);
    }
    this.pending.clear();
  }

  private startTickWatch() {
    if (this.tickTimer) {
      clearInterval(this.tickTimer);
    }
    const rawMinInterval = this.opts.tickWatchMinIntervalMs;
    const minInterval =
      typeof rawMinInterval === "number" && Number.isFinite(rawMinInterval)
        ? Math.max(1, Math.min(30_000, rawMinInterval))
        : 1000;
    const interval = Math.max(this.tickIntervalMs, minInterval);
    this.tickTimer = setInterval(() => {
      if (this.closed) {
        return;
      }
      if (!this.lastTick) {
        return;
      }
      const gap = Date.now() - this.lastTick;
      if (gap > this.tickIntervalMs * 2) {
        this.ws?.close(4000, "tick timeout");
      }
    }, interval);
  }

  private validateTlsFingerprint(): Error | null {
    if (!this.opts.tlsFingerprint || !this.ws) {
      return null;
    }
    const expected = normalizeFingerprint(this.opts.tlsFingerprint);
    if (!expected) {
      return new Error("gateway tls fingerprint missing");
    }
    const socket = (
      this.ws as WebSocket & {
        _socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
      }
    )._socket;
    if (!socket || typeof socket.getPeerCertificate !== "function") {
      return new Error("gateway tls fingerprint unavailable");
    }
    const cert = socket.getPeerCertificate();
    const fingerprint = normalizeFingerprint(cert?.fingerprint256 ?? "");
    if (!fingerprint) {
      return new Error("gateway tls fingerprint unavailable");
    }
    if (fingerprint !== expected) {
      return new Error("gateway tls fingerprint mismatch");
    }
    return null;
  }

  async request<T = Record<string, unknown>>(
    method: string,
    params?: unknown,
    opts?: { expectFinal?: boolean; timeoutMs?: number | null },
  ): Promise<T> {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      throw new Error("gateway not connected");
    }
    const id = randomUUID();
    const frame: RequestFrame = { type: "req", id, method, params };
    if (!validateRequestFrame(frame)) {
      throw new Error(
        `invalid request frame: ${JSON.stringify(validateRequestFrame.errors, null, 2)}`,
      );
    }
    const expectFinal = opts?.expectFinal === true;
    const timeoutMs =
      opts?.timeoutMs === null
        ? null
        : typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
          ? Math.max(1, Math.min(Math.floor(opts.timeoutMs), 2_147_483_647))
          : expectFinal
            ? null
            : this.requestTimeoutMs;
    const p = new Promise<T>((resolve, reject) => {
      const timeout =
        timeoutMs === null
          ? null
          : setTimeout(() => {
              this.pending.delete(id);
              reject(new Error(`gateway request timeout for ${method}`));
            }, timeoutMs);
      this.pending.set(id, {
        resolve: (value) => resolve(value as T),
        reject,
        expectFinal,
        timeout,
      });
    });
    this.ws.send(JSON.stringify(frame));
    return p;
  }
}
