/**
 * # adapters/broker/ibkr — the IB Gateway TWS-socket TRANSPORT (kestrel-7o2.5)
 *
 * The single, shared socket session to a **client-launched IB Gateway**. This is the ADAPTER at the
 * edge (AGENTS: determinism applies to the RUNTIME PATH; the transport produces facts the core
 * folds, so it may use sockets/timers) — but it must NEVER sit on the byte-identical replay path and
 * NEVER leak wall-clock/RNG into canonical state. Its job is exactly: connect, complete the API
 * handshake, track heartbeat/server-time, surface a typed status, and fail LOUD on any drop / auth
 * failure / heartbeat loss so STAND_DOWN is reachable.
 *
 * ## Transport ONLY — no orders (ADR-0034, owner-reviewed)
 * This module places **NO orders**. It writes no `placeOrder`/submit-to-TWS code. Order placement is
 * a later bead (7o2.8 paper / 7o2.10 live) and lands ONLY behind the explicit-arm + L0 risk-clamp +
 * kill-switch envelope (kestrel-7o2.9, already landed in `src/adapters/broker.ts`). The transport is
 * the shared connection those faces will multiplex over — it exposes the ONE guarded client so the
 * feed face and the (future) broker face are two faces of ONE socket, never a second connection.
 *
 * ## Paper-first mode gate
 * `paper` is a Kestrel-enforced gate (config `mode`), not merely a port fact. A `live`-mode transport
 * REFUSES to connect here (fail-closed) because this bead ships no live routing.
 *
 * ## Determinism at the edge
 * The IB client, the wall clock, and the heartbeat scheduler are all INJECTED
 * ({@link IbkrTransportDeps}) so tests drive a fake client + a manual clock + manual timers with no
 * real sockets or timers, and so nothing here reads an ambient `Date.now()` implicitly. Production
 * wires the real {@link IBApi}, `Date.now`, and `globalThis.setInterval`.
 */

import { IBApi, EventName, ErrorCode, isNonFatalError } from "@stoqey/ib";

import { classifyIbkrAccount, describeIbkrConfig, redactAccount, resolveIbkrConfig } from "./config.ts";
import type { IbkrConfig, IbkrConfigInput } from "./config.ts";
import { IbkrConnectionError } from "./errors.ts";
import type { IbkrFailure } from "./errors.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Injected seams — the real IBApi, a clock, and a timer scheduler, all substitutable in tests.
// ─────────────────────────────────────────────────────────────────────────────

/** A listener over the IB event bus. The transport registers narrowly-typed wrappers, so the
 * `unknown[]` here never escapes into a handler body — each `on` call passes a concrete closure. */
type IbListener = (...args: never[]) => void;

/**
 * The NARROW structural surface of {@link IBApi} the transport uses — the shared socket session's
 * request/callback multiplexer. Deliberately excludes every order-placement method (`placeOrder`,
 * `cancelOrder`, …): this bead cannot place orders even by reaching through the client. A test
 * injects a fake that implements exactly this surface (an `EventEmitter` double); production wires
 * the real `IBApi` (a structural superset).
 */
export interface IbClient {
  /** Whether the socket is currently connected (the client's own view). */
  readonly isConnected: boolean;
  /** Open the socket + begin the API handshake. */
  connect(clientId?: number): unknown;
  /** Close the socket. */
  disconnect(): unknown;
  /** Solicit the server's current time (`currentTime` event) — the transport's heartbeat probe. */
  reqCurrentTime(): unknown;
  /** Request the managed account id list (`managedAccounts` event) — part of the handshake. */
  reqManagedAccts(): unknown;
  /** Subscribe to an IB event. */
  on(event: EventName, listener: IbListener): unknown;
  /** Unsubscribe a single listener. */
  removeListener?(event: EventName, listener: IbListener): unknown;
  /** Drop all listeners (used on teardown to release the fake/real emitter). */
  removeAllListeners(event?: EventName): unknown;
}

/** A monotone-enough wall clock in epoch ms — injected so tests advance it by hand and production
 * wires `Date.now`. NEVER read from canonical state; only the edge adapter consults it. */
export type NowFn = () => number;

/** An opaque timer handle from {@link Scheduler.setInterval}. */
export type TimerHandle = unknown;

/** The heartbeat scheduler — injected so tests fire ticks manually (no real timers) and production
 * wires `globalThis.setInterval`/`clearInterval`. */
export interface Scheduler {
  setInterval(fn: () => void, ms: number): TimerHandle;
  clearInterval(handle: TimerHandle): void;
}

/** Construction deps for {@link IbkrTransport}. Every non-determinism source is injected. */
export interface IbkrTransportDeps {
  /** Build the shared IB client for a resolved config. Defaults to a real {@link IBApi}. */
  readonly makeClient?: (config: IbkrConfig) => IbClient;
  /** Wall clock (epoch ms). Defaults to `Date.now`. */
  readonly now?: NowFn;
  /** Heartbeat scheduler. Defaults to `globalThis.setInterval`/`clearInterval`. */
  readonly scheduler?: Scheduler;
  /** Heartbeat probe cadence in ms (how often the watchdog solicits + checks server time). */
  readonly heartbeatIntervalMs?: number;
  /** How stale (ms since last server-time reply) before the heartbeat is declared LOST → degraded. */
  readonly heartbeatStalenessMs?: number;
  /** Optional redacted-diagnostic sink (defaults to no-op — the transport never logs a secret; when
   * wired it receives only already-redacted strings). */
  readonly log?: (line: string) => void;
}

const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
const DEFAULT_HEARTBEAT_STALENESS_MS = 15_000;

// ─────────────────────────────────────────────────────────────────────────────
// BrokerStatus — the typed transport status the session/broker faces observe.
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The typed status of the shared broker transport (kestrel-7o2.5). A single observable snapshot: is
 * the socket connected, is it degraded (and why), which account did the handshake report, and when
 * did the last heartbeat land. `degraded` + `reason` are how a drop / auth failure / heartbeat loss
 * becomes reachable as STAND_DOWN. The `account` is REDACTED here — the full id never leaves the
 * transport's memory.
 */
export interface BrokerStatus {
  /** True once the API handshake completed and the socket is live and not degraded. */
  readonly connected: boolean;
  /** True once a drop / auth failure / heartbeat loss has occurred; latches until reconnect. */
  readonly degraded: boolean;
  /** The redacted account id the gateway's `managedAccounts` reported (never the full number). */
  readonly account: string;
  /** Server epoch-ms of the last heartbeat reply, or `undefined` before the first. */
  readonly lastHeartbeat: number | undefined;
  /** The server time (epoch ms) from the most recent `currentTime` reply, or `undefined`. */
  readonly serverTime: number | undefined;
  /** The logged reason for degradation, or `null` while healthy. NEVER carries a secret. */
  readonly reason: string | null;
}

// ─────────────────────────────────────────────────────────────────────────────
// The transport.
// ─────────────────────────────────────────────────────────────────────────────

type Phase = "idle" | "connecting" | "connected" | "degraded" | "closed";

/**
 * The IB Gateway TWS-socket transport (kestrel-7o2.5). ONE shared session; the feed + (future)
 * broker faces multiplex over its single {@link client}. Fail-closed throughout: any drop / auth
 * failure / heartbeat loss flips {@link status} to `degraded` and throws a typed
 * {@link IbkrConnectionError} on use. Places NO orders.
 */
export class IbkrTransport {
  readonly config: IbkrConfig;
  readonly #makeClient: (config: IbkrConfig) => IbClient;
  readonly #now: NowFn;
  readonly #scheduler: Scheduler;
  readonly #heartbeatIntervalMs: number;
  readonly #heartbeatStalenessMs: number;
  readonly #log: (line: string) => void;

  #client: IbClient | undefined;
  #phase: Phase = "idle";
  // The account identity carried for STATUS/DIAGNOSTICS: the operator's pinned `config.account`, or —
  // on the discovered path — the account the gateway reported. NEVER the barrier's input.
  #fullAccount: string | undefined;
  // GROUND TRUTH — EVERY account the gateway ITSELF reported via `managedAccounts` (the full
  // comma-separated list, order preserved), captured UNCONDITIONALLY (independent of any pinned
  // `config.account`). This, and ONLY this, is what the account barrier classifies — EVERY entry,
  // never just the first: the operator's pinned string is a CLAIM about the endpoint, never evidence
  // of what the socket reached, and a report that MIXES paper and live accounts ("DU…,U…") must fail
  // closed rather than pass on a paper-first entry. On the money path the guarantee is exhaustive, not
  // threat-model-dependent (owner decision, kestrel-7o2.12). A pinned paper-shaped id must NEVER
  // launder a live socket (ADR-0034 §3 — "never the operator's claim / the broker's own account
  // validation as the barrier").
  #reportedAccounts: readonly string[] = [];
  #lastHeartbeat: number | undefined; // server epoch-ms of last currentTime reply
  #lastHeartbeatWall: number | undefined; // local now() at last reply — the staleness anchor
  #serverTime: number | undefined;
  #reason: string | null = null;
  // The TRUE cause of degradation, latched beside #reason (first wins). A dropped socket must NEVER
  // surface as `heartbeat-lost` — the failure kind is the operator's first clue about which wall
  // they hit, so a blanket kind sends them hunting the wrong one.
  #failure: IbkrFailure | undefined;
  #failureCode: number | undefined;
  #failureCause: unknown;
  #heartbeatTimer: TimerHandle | undefined;
  #registered: Array<[EventName, IbListener]> = [];

  constructor(config: IbkrConfig, deps: IbkrTransportDeps = {}) {
    this.config = config;
    this.#makeClient = deps.makeClient ?? defaultMakeClient;
    this.#now = deps.now ?? Date.now;
    this.#scheduler = deps.scheduler ?? defaultScheduler;
    this.#heartbeatIntervalMs = deps.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
    this.#heartbeatStalenessMs = deps.heartbeatStalenessMs ?? DEFAULT_HEARTBEAT_STALENESS_MS;
    this.#log = deps.log ?? (() => {});
    this.#fullAccount = config.account;
  }

  /** Build a transport straight from env/override — resolves the config, then constructs. */
  static resolve(input: IbkrConfigInput = {}, deps: IbkrTransportDeps = {}): IbkrTransport {
    return new IbkrTransport(resolveIbkrConfig(input), deps);
  }

  // ── status ─────────────────────────────────────────────────────────────────

  /** The typed transport status (kestrel-7o2.5) — the observable snapshot the session/broker faces
   * poll. The account is redacted. `degraded` + `reason` are the STAND_DOWN signal. */
  status(): BrokerStatus {
    return {
      connected: this.#phase === "connected",
      degraded: this.#phase === "degraded",
      account: redactAccount(this.#fullAccount),
      lastHeartbeat: this.#lastHeartbeat,
      serverTime: this.#serverTime,
      reason: this.#reason,
    };
  }

  /** True once the handshake completed and the session is healthy (not degraded/closed). */
  get connected(): boolean {
    return this.#phase === "connected";
  }

  /**
   * The ONE shared IB client the feed + (future) broker faces multiplex over — guarded. Throws a
   * typed {@link IbkrConnectionError} if the session is not connected or has gone degraded, so a
   * caller can never use a dead socket (fail-closed; STAND_DOWN reachable). Returning the same client
   * to every face guarantees a SINGLE connection — never a second socket. Note: the narrow
   * {@link IbClient} surface has NO order-placement method, so this cannot place an order.
   */
  client(): IbClient {
    this.assertReady();
    // #client is set whenever phase is "connected" (assertReady enforces that).
    return this.#client as IbClient;
  }

  /** Throw the typed connection error unless the session is connected and healthy. The reconnect
   * verb for a caller that catches this is STAND_DOWN, then a fresh {@link connect}. */
  assertReady(): void {
    if (this.#phase === "connected" && this.#client !== undefined) return;
    if (this.#phase === "degraded") {
      // The failure kind is the one `degrade()` LATCHED — the real cause (a socket disconnect, an
      // auth failure, a lost heartbeat), never a blanket guess. `#failure` is always set whenever
      // the phase is `degraded` (degrade is the only door into it); the fallback is belt-and-braces.
      throw new IbkrConnectionError(
        this.#failure ?? "disconnected",
        `IB Gateway transport is degraded: ${this.#reason ?? "(no reason)"} (fail-closed; STAND_DOWN)`,
        { code: this.#failureCode, cause: this.#failureCause },
      );
    }
    throw new IbkrConnectionError(
      "not-connected",
      `IB Gateway transport is not connected (phase=${this.#phase}) — ${describeIbkrConfig(this.config)} (fail-closed)`,
    );
  }

  // ── connect / handshake ──────────────────────────────────────────────────────

  /**
   * Connect to the client-launched IB Gateway and complete the API handshake (kestrel-7o2.5): open
   * the socket, then resolve only once BOTH `nextValidId`/`connected` AND `managedAccounts` have
   * landed and the first `currentTime` heartbeat has replied. Rejects with a typed
   * {@link IbkrConnectionError} on a fatal IB error (auth/connect failure), a disconnect, or a
   * timeout — never hangs silently. On success, starts the heartbeat watchdog. Places NO orders.
   *
   * @param timeoutMs handshake deadline; a lapse rejects `connect-failed` (fail-closed).
   */
  connect(timeoutMs = 10_000): Promise<BrokerStatus> {
    // Mode gate FIRST — `live` is refused here regardless of port/account (ADR-0034 §a). Paper-first
    // is a Kestrel gate, not merely an IBKR fact; this bead ships no live routing.
    if (this.config.mode === "live") {
      return Promise.reject(
        new IbkrConnectionError(
          "mode-gate",
          "IB Gateway transport refuses live mode — this bead is transport-only and ships no live routing; live requires the human-signed arm envelope (kestrel-7o2.9/7o2.10). Fail-closed.",
        ),
      );
    }
    if (this.#phase === "connecting" || this.#phase === "connected") {
      return Promise.reject(
        new IbkrConnectionError(
          "connect-failed",
          `IB Gateway transport already ${this.#phase} — refusing a second connection on the shared session (fail-closed)`,
        ),
      );
    }

    this.#phase = "connecting";
    this.#reason = null;
    this.#failure = undefined;
    this.#failureCode = undefined;
    this.#failureCause = undefined;
    // KILL THE ZOMBIE FIRST — LISTENERS *AND* SOCKET (kestrel-7o2.16 item 2, then 7o2.17 item 1).
    //
    // A reconnect (after a degrade) used to reset the ledger and overwrite `#client` while leaving the
    // PREVIOUS client's listeners attached — orphaned closures that still hold `this`. A dead socket
    // then steered the live one two ways: its death rattle (disconnected/connectionClosed/a fatal)
    // force-degraded the FRESH, healthy session; and, worse, its `currentTime` reply refreshed the NEW
    // session's staleness anchor, so a silently dead new socket kept rendering as LIVE (kestrel-rs4/8kc,
    // through the back door).
    //
    // Detaching alone was NOT enough (kestrel-7o2.17). The old TCP session stayed OPEN — `degrade()`
    // leaves it open BY DESIGN so `status()` stays truthful — and IB grants a clientId EXCLUSIVELY to
    // one live socket, refusing a second connection on the same id (error 326). So the reconnect built
    // a second IBApi against an id the first session still held and was REFUSED BY THE GATEWAY: the
    // reconnect failed at exactly the moment it exists for, a mid-session drop. A still-open zombie is
    // also still receiving IB's lifecycle callbacks, so two pumps could race on one ledger. CLOSE the
    // prior socket — deterministically, before the new one is built — so at most ONE socket is ever
    // live on the configured clientId.
    this.stopHeartbeat();
    this.closeClient();
    const client = this.#makeClient(this.config);
    this.#client = client;

    return new Promise<BrokerStatus>((resolve, reject) => {
      let settled = false;
      let sawHandshakeId = false;
      let sawAccounts = false;
      let sawFirstBeat = false;

      const finish = (fn: () => void): void => {
        if (settled) return;
        settled = true;
        this.#scheduler.clearInterval(deadline);
        fn();
      };

      const fail = (err: IbkrConnectionError): void => {
        finish(() => {
          this.degrade(err.failure, err.message, { code: err.code, cause: err.cause });
          reject(err);
        });
      };

      /**
       * A DROP SIGNAL from IB — the socket closed, the peer disconnected, or a fatal error landed.
       *
       * BEFORE the handshake settles this rejects `connect()`. AFTER it settles it degrades
       * IMMEDIATELY. That second half is the whole point: these listeners stay live past `settled`,
       * so IB's own explicit "I am gone" signals take effect the instant they fire rather than
       * routing through `finish()` (which no-ops once settled) and leaving `status().connected ===
       * true` until the watchdog next polls. A dead socket rendered live — for up to a heartbeat
       * interval — is exactly the bug class this runtime treats as first-class (kestrel-rs4).
       * The heartbeat watchdog remains as the BACKSTOP for a socket that dies silently, emitting
       * nothing at all (belt and braces).
       */
      const drop = (
        failure: IbkrFailure,
        message: string,
        options: { cause?: unknown; code?: number | undefined } = {},
      ): void => {
        if (settled) {
          this.degrade(failure, message, options);
          return;
        }
        fail(new IbkrConnectionError(failure, message, options));
      };

      const maybeReady = (): void => {
        if (sawHandshakeId && sawAccounts && sawFirstBeat && !settled) {
          // THE ACCOUNT BARRIER (ADR-0034 §3's promised SECOND barrier) — the LAST thing before the
          // session is declared usable, and therefore BEFORE any tape is opened, any gate is built,
          // or any order could be transmitted. Everything above this line guards the mode STRING; a
          // misconfigured port cannot falsify a mode string, so nothing above it can tell that this
          // socket is attached to a REAL account. Only the accounts the gateway ITSELF reported can —
          // so it classifies EVERY entry of #reportedAccounts (the REPORT, ground truth about what the
          // socket reached), NEVER the operator's pinned #fullAccount CLAIM. A pinned paper-shaped
          // string must never be able to vouch for a live socket.
          //
          // Classify ALL, not the first (owner decision, kestrel-7o2.12): a mixed report "DU…,U…" must
          // NOT pass on its paper-first entry. On the money path "impossible because a real login
          // reports one environment" has been wrong before, so the guarantee is exhaustive.
          //
          // Refuse LOUDLY (fail-closed) — always the typed `live-account` kind, socket CLOSED — when:
          //   (a) the gateway's report is not POSITIVELY all-paper: EITHER it is empty/absent, OR ANY
          //       reported account is live (`U…`) or unclassifiable. Paper requires a NON-EMPTY list
          //       whose EVERY entry matches the DU/DF allowlist — never assume paper for what we cannot
          //       classify, and never let one paper entry vouch for the rest; or
          //   (b) an account is PINNED and it does NOT equal ANY of the reported (all-paper) accounts
          //       (the pin is a claim about the endpoint; the report is what the socket reached — never
          //       trust the claim over the report, even when both are paper-shaped).
          const reported = this.#reportedAccounts;
          const allPaper =
            reported.length > 0 && reported.every((a) => classifyIbkrAccount(a) === "paper");
          const anyLive = reported.some((a) => classifyIbkrAccount(a) === "live");
          const refuse = (message: string): void => {
            finish(() => {
              const err = new IbkrConnectionError("live-account", message);
              // LATCH the reason, then KILL THE SOCKET. Unlike every other failure here, this one
              // means the handshake SUCCEEDED — we are attached to a real gateway on a real account
              // — so `degrade()` alone (which deliberately keeps the client referenced so `status()`
              // stays truthful) would leave a live socket open behind the refusal. It is closed.
              this.degrade(err.failure, err.message, {});
              this.disconnect();
              reject(err);
            });
          };
          if (!allPaper) {
            refuse(
              anyLive
                ? `IB Gateway handshake reported a LIVE, REAL-MONEY account (a U-prefixed id) among the accounts at ${describeIbkrConfig(this.config)} — Kestrel is NOT armed for live (live routing is owner-gated, kestrel-7o2.10), so this socket must not be used. EVERY account the gateway reports must be a PAPER account (DU/DF) for a paper session; a report that mixes paper and live accounts is refused just as loudly as an all-live one (a paper-first entry never vouches for the rest). Check the port (IB Gateway paper is 4002, TWS paper 7497) and which account(s) the gateway is logged into. Fail-closed; the socket has been closed.`
                : `IB Gateway handshake reported NO account, or one Kestrel CANNOT CLASSIFY as paper, at ${describeIbkrConfig(this.config)} — an absent or unclassifiable account is not evidence of a paper account, so it is refused exactly as loudly as a live one (fail-closed; never assume paper). EVERY reported account must be DU/DF-prefixed. The socket has been closed.`,
            );
            return;
          }
          const pinned = this.config.account;
          if (pinned !== undefined && !reported.some((a) => a === pinned.trim())) {
            refuse(
              `IB Gateway handshake: the PINNED account does NOT match ANY account the gateway ITSELF reported at ${describeIbkrConfig(this.config)} — the pin is only a CLAIM about the endpoint, while the gateway's report is what the socket actually reached, and Kestrel never trusts the claim over the report (ADR-0034 §3). Both may be paper-shaped and this is STILL refused: a mismatch means the operator cannot vouch for which account this socket is on. Fail-closed; the socket has been closed.`,
            );
            return;
          }
          finish(() => {
            this.#phase = "connected";
            this.startHeartbeat();
            this.#log(`connected: ${describeIbkrConfig(this.config)}`);
            resolve(this.status());
          });
        }
      };

      // Handshake ids: `connected` and `nextValidId` both signal the socket + API are up. Either
      // suffices; both are wired so a fake that emits only one still completes the handshake.
      this.register(client, EventName.connected, () => {
        sawHandshakeId = true;
        maybeReady();
      });
      this.register(client, EventName.nextValidId, () => {
        sawHandshakeId = true;
        maybeReady();
      });

      // Account discovery. GROUND TRUTH FIRST: capture EVERY account the gateway ITSELF reported (the
      // full comma-separated list), UNCONDITIONALLY — independent of any pinned `config.account`. The
      // barrier classifies ALL of THESE (report), never the operator's pinned CLAIM. The OLD code
      // discarded the gateway's report whenever an account was pinned, so a pinned paper-shaped string
      // classified `paper` while the socket was attached to a live account — the exact bypass ADR-0034
      // §3 forbids; and it captured only the FIRST reported account, so a MIXED report "DU…,U…" would
      // have passed on its paper-first entry. Capture the whole list; the barrier below checks EVERY one.
      this.register(client, EventName.managedAccounts, (accountsList: string) => {
        const reported = accountsList.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
        this.#reportedAccounts = reported;
        // For STATUS/DIAGNOSTICS only: on the discovered path (nothing pinned) adopt the FIRST reported
        // id so `status()` names an account the session reached. A pinned id is left as the operator set
        // it; the barrier below PROVES it equals one of the reported accounts before the session is ever
        // usable. Display adopts ONE; the barrier classifies ALL.
        const first = reported[0];
        if (this.config.account === undefined && first !== undefined) {
          this.#fullAccount = first;
        }
        sawAccounts = true;
        maybeReady();
      });

      // Heartbeat: the first `currentTime` reply proves the round-trip is alive and completes the
      // handshake; every later reply refreshes the staleness anchor.
      this.register(client, EventName.currentTime, (time: number) => {
        this.recordHeartbeat(time);
        sawFirstBeat = true;
        maybeReady();
      });

      // Fatal IB errors → reject (pre-handshake) or degrade on the spot (post-handshake); non-fatal
      // (market-data warnings, 2100–2999) are logged redacted and ignored, never a silent swallow of
      // a real failure.
      //
      // POST-handshake the degrade is GATED to CONNECTION-LEVEL messages (kestrel-7o2.16). IB
      // multiplexes request-scoped AND connection-scoped errors onto this one event, telling them
      // apart by `reqId` (-1 ⇒ "about the CONNECTION"). Degrading on any fatal code regardless of
      // reqId means that once the feed face multiplexes chain/market-data requests over this SHARED
      // socket, ONE bad contract query (200) would STAND_DOWN the whole live session. A request-scoped
      // error belongs to the REQUESTING CALLER — it is surfaced (logged) for them, never a session kill.
      //
      // PRE-handshake, nothing has been requested yet, so any fatal is treated as connection-level and
      // still rejects `connect()` — unchanged, fail-closed.
      this.register(client, EventName.error, (err: Error, code: ErrorCode, reqId?: number) => {
        if (isNonFatalError(code, err)) {
          this.#log(`ib info code=${code}`);
          return;
        }
        // An absent reqId is treated as connection-level: fail-closed, never a shrug.
        const scope = reqId ?? IB_CONNECTION_REQ_ID;
        if (settled && !isConnectionLevelError(code, scope)) {
          this.#log(
            `ib request-scoped error code=${code} reqId=${scope} — surfaced to the requesting caller; the shared session is unaffected`,
          );
          return;
        }
        drop(
          // The default is context-honest, never a blanket "auth-failed": an unrecognised fatal during
          // the handshake is a failed connect; one on a live connection-level message is a lost link.
          failureForErrorCode(code, settled ? "disconnected" : "connect-failed"),
          settled
            ? `IB Gateway FATAL connection-level error on a live session (code=${code}, reqId=${scope}) — the socket is no longer trustworthy; fail-closed`
            : `IB Gateway error during handshake (code=${code}) — fail-closed`,
          { code, cause: err },
        );
      });

      // A socket close/disconnect: a failed handshake before ready, an immediate degrade after.
      this.register(client, EventName.disconnected, () => {
        drop(
          "disconnected",
          settled
            ? "IB Gateway socket disconnected after the handshake (fail-closed)"
            : "IB Gateway socket disconnected during handshake (fail-closed)",
        );
      });
      this.register(client, EventName.connectionClosed, () => {
        drop(
          "disconnected",
          settled
            ? "IB Gateway closed the connection after the handshake (fail-closed)"
            : "IB Gateway connection closed during handshake (fail-closed)",
        );
      });

      // Deadline — reuse the injected scheduler so tests drive it deterministically (no real timer).
      const deadline = this.#scheduler.setInterval(() => {
        fail(
          new IbkrConnectionError(
            "connect-failed",
            `IB Gateway handshake timed out after ${timeoutMs}ms (connected=${sawHandshakeId} account=${sawAccounts} heartbeat=${sawFirstBeat}) — fail-closed`,
          ),
        );
      }, timeoutMs);

      // Kick the handshake. The gateway emits `nextValidId`/`connected` on socket-up; we then solicit
      // the account list and the first heartbeat.
      try {
        client.connect(this.config.clientId);
        client.reqManagedAccts();
        client.reqCurrentTime();
      } catch (cause) {
        fail(
          new IbkrConnectionError("connect-failed", "IB Gateway connect() threw (fail-closed)", {
            cause,
          }),
        );
      }
    });
  }

  // ── heartbeat ────────────────────────────────────────────────────────────────

  /** Record a fresh server-time heartbeat. IB `currentTime` is epoch SECONDS; we hold epoch-ms and
   * stamp the local staleness anchor with the injected clock (never an ambient `Date.now`). */
  private recordHeartbeat(serverTimeSeconds: number): void {
    this.#serverTime = serverTimeSeconds * 1000;
    this.#lastHeartbeat = this.#serverTime;
    this.#lastHeartbeatWall = this.#now();
  }

  /** Start the heartbeat watchdog: each tick solicits a fresh `currentTime` and, if the last reply
   * is older than the staleness bound, declares the heartbeat LOST → degraded (fail-closed). Uses the
   * injected scheduler so tests fire ticks by hand. */
  private startHeartbeat(): void {
    this.stopHeartbeat();
    this.#heartbeatTimer = this.#scheduler.setInterval(() => {
      this.pulse();
    }, this.#heartbeatIntervalMs);
  }

  private stopHeartbeat(): void {
    if (this.#heartbeatTimer !== undefined) {
      this.#scheduler.clearInterval(this.#heartbeatTimer);
      this.#heartbeatTimer = undefined;
    }
  }

  /**
   * One heartbeat watchdog step (exposed for the owner smoke + deterministic tests): solicit a fresh
   * server time, then check staleness against the injected clock. If the socket dropped, or no reply
   * has landed within the staleness bound, flip to `degraded` with a typed reason. A no-op once
   * degraded/closed.
   */
  pulse(): void {
    if (this.#phase !== "connected") return;
    const client = this.#client;
    if (client === undefined || !client.isConnected) {
      this.degrade("disconnected", "IB Gateway socket is no longer connected (heartbeat)");
      return;
    }
    const anchor = this.#lastHeartbeatWall;
    if (anchor !== undefined && this.#now() - anchor > this.#heartbeatStalenessMs) {
      this.degrade(
        "heartbeat-lost",
        `no IB Gateway heartbeat for ${this.#now() - anchor}ms (> ${this.#heartbeatStalenessMs}ms staleness bound)`,
      );
      return;
    }
    // Solicit the next server-time reply; the `currentTime` handler refreshes the anchor.
    try {
      client.reqCurrentTime();
    } catch (cause) {
      this.degrade("disconnected", "reqCurrentTime threw on the IB Gateway socket (heartbeat)", {
        cause,
      });
    }
  }

  // ── degrade / teardown ───────────────────────────────────────────────────────

  /** Flip the session to degraded with a typed, secret-free reason (kestrel-7o2.5). Idempotent —
   * the FIRST failure/reason/cause wins (a latch), so a cascade of drops does not overwrite the root
   * cause. The failure KIND is RECORDED, not discarded: a later {@link assertReady}/{@link client}
   * rethrows THIS failure, so a hung-up socket reports `disconnected` and only a genuinely missed
   * heartbeat reports `heartbeat-lost`. Stops the heartbeat; the shared client stays referenced only
   * so `status()` stays truthful. STAND_DOWN is reachable: every subsequent use throws. */
  degrade(
    failure: IbkrFailure,
    reason: string,
    options: { cause?: unknown; code?: number | undefined } = {},
  ): void {
    if (this.#phase === "degraded" || this.#phase === "closed") return;
    this.#phase = "degraded";
    this.#reason = reason;
    this.#failure = failure;
    this.#failureCode = options.code;
    this.#failureCause = options.cause;
    this.stopHeartbeat();
    this.#log(`degraded: ${reason} [${failure}] (${describeIbkrConfig(this.config)})`);
  }

  /** Cleanly close the shared session (kestrel-7o2.5): stop the heartbeat, drop every listener, and
   * disconnect the socket. Fail-closed and idempotent — safe to call from a catch/STAND_DOWN path. */
  disconnect(): void {
    this.stopHeartbeat();
    this.closeClient();
    this.#phase = "closed";
  }

  // ── internal ─────────────────────────────────────────────────────────────────

  /**
   * Tear the CURRENT `#client` down completely and forget it: every listener off, then the socket
   * closed. The single socket-teardown truth, shared by {@link disconnect} and by {@link connect}'s
   * zombie-kill — so the reconnect path and the shutdown path cannot drift (the drift is what
   * kestrel-7o2.17 was: `disconnect()` closed the socket, `connect()` only detached listeners, and the
   * leaked socket kept the clientId IB would then refuse to grant twice).
   *
   * Order matters: listeners come off BEFORE `disconnect()`, so the socket's own death rattle
   * (`disconnected`/`connectionClosed`) cannot reach a handler and degrade the session we are closing
   * on purpose — or, on the reconnect path, the fresh one about to replace it.
   *
   * Idempotent and fail-closed: a disconnect on an already-dead socket is a no-op, never a crash.
   * Clearing `#client` is safe for {@link status} truthfulness — status reads only `#phase`/`#reason`/
   * the account/heartbeat fields, never the client — while `pulse`/`client` are already gated on phase.
   */
  private closeClient(): void {
    const client = this.#client;
    this.detachListeners();
    this.#client = undefined;
    if (client === undefined) return;
    try {
      client.removeAllListeners();
      client.disconnect();
    } catch {
      // A disconnect on an already-dead socket is a no-op, never a crash (fail-closed).
    }
  }

  /**
   * Remove EVERY listener this transport registered on the CURRENT `#client` and empty the ledger.
   * The listener half of {@link closeClient}'s teardown (which both {@link disconnect} and
   * {@link connect}'s zombie-kill route through): it is a per-listener removal, never the blanket
   * `removeAllListeners()` hammer, so it stays honest on a client the transport does not exclusively
   * own. Idempotent (the ledger empties).
   */
  private detachListeners(): void {
    const client = this.#client;
    if (client !== undefined) {
      for (const [event, listener] of this.#registered) {
        client.removeListener?.(event, listener);
      }
    }
    this.#registered = [];
  }

  /** Register a narrowly-typed listener and remember it for teardown. The `as` casts bridge the
   * concrete closure to the client's broad `IbListener` surface — the args are the exact IB event
   * shapes documented on `IBApi.on`. */
  private register<A extends unknown[]>(
    client: IbClient,
    event: EventName,
    listener: (...args: A) => void,
  ): void {
    const wrapped = listener as unknown as IbListener;
    client.on(event, wrapped);
    this.#registered.push([event, wrapped]);
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Production defaults — the real IBApi + global timers. Substituted wholesale in tests.
// ─────────────────────────────────────────────────────────────────────────────

/** IB's convention for "this message is about the CONNECTION, not a request" (`ErrorCode.NO_VALID_ID`). */
const IB_CONNECTION_REQ_ID = -1;

/** IB 1101 — "Connectivity between IB and TWS has been RESTORED — data LOST." The link dropped and
 * came back; ticks were missed. `@stoqey/ib` calls it fatal (it is < 2100) and it IS a gap, so it
 * degrades — but the honest kind is `disconnected`, not an auth wall. */
const IB_CONNECTIVITY_RESTORED_DATA_LOST = 1101;
/** IB 1102 — the same restoration with data MAINTAINED. Still a link that went away underneath us. */
const IB_CONNECTIVITY_RESTORED_DATA_MAINTAINED = 1102;

/**
 * The CONNECTION-ERROR FAMILY: codes that speak about the socket/uplink itself rather than any one
 * request. These degrade the shared session even if IB happened to stamp a reqId on them.
 */
const CONNECTION_LEVEL_ERROR_CODES: ReadonlySet<number> = new Set<number>([
  ErrorCode.CONNECT_FAIL, // 502
  ErrorCode.NOT_CONNECTED, // 504
  ErrorCode.FAIL_READ_MESSAGE, // 586
  ErrorCode.FAIL_CONNECTION_LOST_BETWEEN_SERVER_AND_TWS, // 1100
  IB_CONNECTIVITY_RESTORED_DATA_LOST, // 1101
  IB_CONNECTIVITY_RESTORED_DATA_MAINTAINED, // 1102
]);

/**
 * Is this fatal IB error about the CONNECTION (⇒ degrade the shared session, fail-closed) or about a
 * single REQUEST (⇒ the requesting caller's problem, never the session's)? `reqId === -1` is IB's own
 * marker for a connection-scoped message; the connection-error family is honoured regardless of reqId.
 */
function isConnectionLevelError(code: number, reqId: number): boolean {
  return reqId === IB_CONNECTION_REQ_ID || CONNECTION_LEVEL_ERROR_CODES.has(code);
}

/**
 * Map a FATAL, CONNECTION-LEVEL IB error code to the typed {@link IbkrFailure} kind an operator can
 * act on. The connectivity family (the socket is gone / unreadable / the uplink dropped and came
 * back) is an honest `disconnected` — reporting it as `auth-failed` or `heartbeat-lost` would send
 * the operator hunting the wrong wall, which is exactly what 1101/1102 (a mere socket flap) used to
 * do. An unrecognised code still degrades (fail-closed — an unknown fatal is never shrugged off), but
 * under the caller's context-honest `fallback` rather than a blanket "auth-failed" the transport
 * cannot actually vouch for.
 */
function failureForErrorCode(code: number, fallback: IbkrFailure): IbkrFailure {
  switch (code) {
    case ErrorCode.CONNECT_FAIL:
      return "connect-failed";
    case ErrorCode.NOT_CONNECTED:
    case ErrorCode.FAIL_READ_MESSAGE:
    case ErrorCode.FAIL_CONNECTION_LOST_BETWEEN_SERVER_AND_TWS:
    case IB_CONNECTIVITY_RESTORED_DATA_LOST:
    case IB_CONNECTIVITY_RESTORED_DATA_MAINTAINED:
      return "disconnected";
    case ErrorCode.NO_TRADING_PERMISSIONS:
      return "auth-failed";
    default:
      return fallback;
  }
}

/** Build the real shared {@link IBApi} for a resolved config. The narrow {@link IbClient} surface it
 * is returned as excludes every order method, so no caller can place an order through the transport.
 * (The `IBApi` is a structural superset; the cast narrows, it does not widen.) */
function defaultMakeClient(config: IbkrConfig): IbClient {
  const api = new IBApi({ host: config.host, port: config.port });
  return api as unknown as IbClient;
}

/** The real heartbeat scheduler over global timers. Kept off the deterministic core — only the edge
 * transport uses it. */
const defaultScheduler: Scheduler = {
  setInterval: (fn, ms) => setInterval(fn, ms),
  clearInterval: (handle) => {
    clearInterval(handle as ReturnType<typeof setInterval>);
  },
};
