/**
 * # adapters/broker/ibkr/contract — the CONTRACT-RESOLUTION layer (kestrel-7o2.6)
 *
 * The one place a **Kestrel identity** (an already-resolved {@link OrderIntent}: an instrument, and
 * — for an option — a strike + a right, ADR-0017) is mapped onto the **broker's own definition** of
 * that instrument. It answers exactly one question: *which contract at the venue IS this leg?*
 *
 * ## Two entry points, and the type is what keeps them apart (kestrel-7o2.24)
 * {@link resolveContract} is the ORDER path: it returns an {@link IbkrContract}, the TRADABLE union
 * (`STK` | `OPT`). {@link resolveUnderlier} is the READ path: it returns an
 * {@link IbkrUnderlierContract} (`STK` | `IND`) — what a chain hangs off and what a feed's spot line
 * reads. An INDEX (`IND` — the NDX/XND/SPX class of cash-settled index options) is a number, not an
 * instrument: it lives only in the second union, so the order path cannot be handed one and `tsc`
 * says so. A structural exclusion, not a runtime guard that could go un-wired.
 *
 * ## TRANSMITTER, never a re-pricer (the core doctrine)
 * The engine has already resolved the price before the intent ever reaches a venue face (RUNTIME §4:
 * a silent mid is forbidden; the intent carries its price-resolution receipt). So this layer NEVER
 * reads, derives, rounds, or improves a price — it does not touch `intent.px`, and the
 * {@link IbkrContract} it returns carries **no price field of any kind** (no px / limit / bid / ask /
 * minTick). That is a TYPE-level guarantee, asserted structurally in `tests/ibkr.contract.test.ts`:
 * a contract is an IDENTITY, and a price that rode in on an identity is a price nobody authored.
 *
 * ## FAIL-CLOSED (never a guessed contract)
 * A half-specified leg (strike xor right), an option with no supplied expiry, no matching definition,
 * an incomplete definition, or — the trap this layer exists to catch — MORE THAN ONE matching
 * definition, all raise a LOUD typed {@link IbkrContractError} with a logged reason, which the caller
 * routes to STAND_DOWN. **Never a pick.** Two definitions that both "look right" (an a.m.- vs a
 * p.m.-settled class, two trading classes, two primary exchanges) are two different instruments; a
 * layer that silently chooses one has silently authored a trade the author never wrote. The gateway's
 * definition is the authority — an OCC local symbol, an expiry, an exchange are taken FROM it, never
 * hand-rolled toward it.
 *
 * ## Places NO orders
 * This module issues READ-ONLY definition lookups (`reqContractDetails`, `reqSecDefOptParams`). It
 * contains no order-submission path whatsoever, and the narrow {@link IbContractClient} surface it
 * speaks through declares none — so it cannot submit one even by reaching through the client. Order
 * submission is a later bead (7o2.8 paper / 7o2.10 live), behind the human-signed arm envelope
 * (7o2.9). The tests assert this at the source level and with a runtime spy.
 *
 * ## One socket, determinism at the edge
 * The IB client is INJECTED (as the transport injects it), so unit tests drive an in-memory double
 * with no real socket and no real timer; production passes {@link IbkrTransport}'s ONE shared client
 * via {@link contractClientOf} — never a second connection. The request-id minter and the deadline
 * scheduler are injected for the same reason.
 *
 * ## KNOWN INTERACTION with the transport's fatal-error rule (observed against the LIVE gateway)
 * The transport (7o2.5) degrades the shared session on ANY IB error `isNonFatalError` does not
 * whitelist — including **200 ("no security definition")**, which is REQUEST-scoped and entirely
 * expected on this layer's fail-closed path (a bogus strike is *supposed* to be refused). So an
 * `unresolvable` refusal ALSO degrades the transport, and the next lookup throws
 * {@link IbkrConnectionError} until the session reconnects. The direction is safe — it fails MORE
 * closed, never less, and STAND_DOWN is exactly where both paths route — but a request-scoped error
 * should not fell the socket. Tuning that classification is the transport's call (the IBKR hardening
 * bead), NOT this layer's: nothing here may quietly re-open or re-classify a session the transport
 * has declared dead.
 */

import { EventName, SecType, OptionType, isNonFatalError } from "@stoqey/ib";
import type { Contract, ContractDetails } from "@stoqey/ib";

import { redactAccount } from "./config.ts";
import { IbkrContractError } from "./errors.ts";
import type { IbkrTransport, Scheduler, TimerHandle } from "./transport.ts";
import type { OrderIntent } from "../../../engine/index.ts";
import type { Right } from "../../../bus/index.ts";

/** IB's "No security definition has been found for the request" (error 200) — the venue's own way of
 * saying the identity does not exist. `@stoqey/ib`'s {@link import("@stoqey/ib").ErrorCode} enum has
 * no member for it, so it is named here rather than left as a bare literal at the branch. */
export const IB_NO_SECURITY_DEFINITION = 200;

/** Publication-safe routing defaults (ARCHITECTURE §7: nothing here reveals an application). SMART
 * is IB's own documented default router and USD the listing currency of the generic US tickers the
 * package ships fixtures for; both are always overridable per-call. Never a guessed EXPIRY, though —
 * an expiry has no safe default, so it is required from the caller (see {@link IbkrContractDeps}). */
export const IBKR_DEFAULT_EXCHANGE = "SMART";
export const IBKR_DEFAULT_CURRENCY = "USD";
const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;

// ─────────────────────────────────────────────────────────────────────────────
// The resolved contract — an IDENTITY. No price rides on it.
// ─────────────────────────────────────────────────────────────────────────────

/** The fields every resolved contract carries, whatever the asset class. Note what is ABSENT and
 * always will be: any price. The contract layer is a transmitter, never a re-pricer. */
interface IbkrContractBase {
  /** IB's unique contract id — the authoritative handle a later order face transmits against. */
  readonly conId: number;
  readonly symbol: string;
  /** The destination exchange the gateway's definition names (e.g. `SMART`). */
  readonly exchange: string;
  readonly currency: string;
  /** The gateway's OWN local symbol (for an option, the OCC symbol) — never hand-rolled. */
  readonly localSymbol: string;
  /** The gateway's own trading class, when it named one (e.g. `SPY`) — the disambiguator. */
  readonly tradingClass: string | undefined;
}

/** A resolved SPOT/EQUITY contract (ADR-0017: strike AND right both absent on the leg). */
export interface IbkrEquityContract extends IbkrContractBase {
  readonly kind: "equity";
  readonly secType: "STK";
  /** Shares are 1:1 — the multiplier is carried for a uniform sizing surface, not derived. */
  readonly multiplier: 1;
  /** The listing exchange the gateway names for a SMART-routed equity (e.g. `ARCA`), when known. */
  readonly primaryExchange: string | undefined;
}

/**
 * A resolved INDEX contract (`secType: IND`) — the underlier of a **cash-settled index option**
 * (NDX/XND/SPX class), and the thing a chain hangs off.
 *
 * ## It is NOT a member of {@link IbkrContract}, and that is the whole point
 * An index is a NUMBER, not an instrument: it cannot be bought, sold, held, or delivered. So it is
 * deliberately kept OUT of the tradable {@link IbkrContract} union rather than admitted and then
 * guarded. {@link resolveContract} — the ORDER path's entry point — can never return one, and the
 * order face's {@link IbkrContract}-typed surfaces (the contract book, `submit`, `preflight`) cannot
 * be handed one: `tsc` refuses. That is a STRUCTURAL guarantee, not a runtime check that could be
 * forgotten, mis-wired, or deleted. A feed subscribes to an index (that is where the spot the strike
 * window centres on comes from — see {@link IbkrUnderlierContract}); an order path never touches one.
 *
 * Note what is ABSENT: no `multiplier`, no `primaryExchange`. An index has no contract multiplier
 * (there is no contract), so the field that would let it be SIZED does not exist to be read.
 */
export interface IbkrIndexContract extends IbkrContractBase {
  readonly kind: "index";
  readonly secType: "IND";
}

/**
 * What an option chain can hang off, and what a feed's SPOT line can be: an equity/ETF underlier
 * (`STK`) or an index underlier (`IND`). Which one a symbol IS is never inferred from the symbol —
 * `XND` could be anything — it is stated by the caller ({@link resolveUnderlier}'s `secType`).
 */
export type IbkrUnderlierContract = IbkrEquityContract | IbkrIndexContract;

/** Anything the FEED may subscribe to for a quote — an underlier (equity or index) or an option leg.
 * Strictly wider than {@link IbkrContract} (the TRADABLE union): a quote is not a trade. */
export type IbkrQuotedContract = IbkrUnderlierContract | IbkrOptionContract;

/** Which asset class an underlier IS. Never guessed from the symbol: stated by the caller. */
export type UnderlierSecType = "STK" | "IND";

/** A resolved OPTION contract (ADR-0017: strike AND right both present on the leg). Every field is
 * the GATEWAY's, taken from its definition — the expiry, the multiplier, the OCC local symbol. */
export interface IbkrOptionContract extends IbkrContractBase {
  readonly kind: "option";
  readonly secType: "OPT";
  /** Contract multiplier as the venue defines it (dollars per point). Read, never assumed. */
  readonly multiplier: number;
  /** The venue's last-trading-day, `YYYYMMDD`. Never guessed — echoed from the definition. */
  readonly expiry: string;
  readonly strike: number;
  readonly right: Right;
}

/**
 * A **TRADABLE** broker contract resolved from a Kestrel identity (kestrel-7o2.6) — the discriminated
 * union a venue face transmits against. It carries NO price: not `px`, not a limit, not a quote, not
 * a tick size. The price on the {@link OrderIntent} is the engine's, already resolved with a receipt;
 * this layer never reads it and never lets a venue-side number impersonate one.
 *
 * {@link IbkrIndexContract} is NOT a member: an index is not tradable, so the order path's own type
 * excludes it and no guard is needed to keep it out (see that type's docblock). Widen to
 * {@link IbkrQuotedContract} for the read-only feed side, never here.
 */
export type IbkrContract = IbkrEquityContract | IbkrOptionContract;

/** The gateway's option chain for one underlier + trading class — the definition source a caller
 * picks a strike/expiry FROM (it does not pick for them). Carries no price. */
export interface IbkrOptionChain {
  readonly symbol: string;
  readonly exchange: string;
  readonly tradingClass: string;
  readonly multiplier: number;
  /** Sorted ascending — the same gateway reply always yields the same order (determinism at the edge). */
  readonly expirations: readonly string[];
  /** Sorted ascending. */
  readonly strikes: readonly number[];
}

// ─────────────────────────────────────────────────────────────────────────────
// Injected seams — the narrow READ-ONLY definition surface of the shared IB client.
// ─────────────────────────────────────────────────────────────────────────────

/** A listener over the IB event bus (the transport's convention — concrete closures are wrapped at
 * the registration site, so the `never[]` never escapes into a handler body). */
type IbListener = (...args: never[]) => void;

/**
 * The NARROW structural surface of the shared IB client this layer speaks through: the two READ-ONLY
 * contract-definition requests and the event bus. It declares NO order-submission method at all — the
 * contract layer cannot transmit an order even by reaching through the client (order submission is
 * 7o2.8/7o2.10, behind the 7o2.9 arm envelope). A test injects a double implementing exactly this;
 * production passes the transport's ONE shared client (a structural superset) via
 * {@link contractClientOf} — never a second socket.
 */
export interface IbContractClient {
  /** Ask the gateway for every definition matching a (possibly partial) contract query. */
  reqContractDetails(reqId: number, contract: Contract): unknown;
  /** Ask the gateway for an underlier's option-chain parameters (expirations/strikes/multiplier). */
  reqSecDefOptParams(
    reqId: number,
    underlyingSymbol: string,
    futFopExchange: string,
    underlyingSecType: string,
    underlyingConId: number,
  ): unknown;
  on(event: EventName, listener: IbListener): unknown;
  removeListener(event: EventName, listener: IbListener): unknown;
}

/** Everything {@link resolveContract} is wired with. Every non-determinism source (the socket, the
 * request-id minter, the deadline timer) is injected, so unit tests run with none of them. */
export interface IbkrContractDeps {
  /** The shared IB client — the transport's ONE session in production (see {@link contractClientOf}). */
  readonly client: IbContractClient;
  /** Mints a unique IB request id per lookup. Defaults to a module-local monotone counter (a counter,
   * never an RNG — this is the edge, and it still must not be a source of nondeterminism). */
  readonly nextReqId?: (() => number) | undefined;
  /** The OPTION expiry to resolve (`YYYYMMDD`). REQUIRED for an option leg and REFUSED-if-absent —
   * an expiry is never inferred from a clock or a chain, because "the nearest one" is a trade the
   * author did not write. Ignored (and unnecessary) for a spot/equity leg. */
  readonly expiry?: string | undefined;
  /** Destination exchange for the query (default {@link IBKR_DEFAULT_EXCHANGE}). */
  readonly exchange?: string | undefined;
  /** Trading currency for the query (default {@link IBKR_DEFAULT_CURRENCY}). */
  readonly currency?: string | undefined;
  /** Trading class to pin — the ONLY sanctioned way to disambiguate two classes on one underlier
   * (e.g. an a.m.- vs a p.m.-settled index class). Absent + two matches ⇒ a typed refusal, never a pick. */
  readonly tradingClass?: string | undefined;
  /** Lookup deadline (ms). A lapse is a typed `lookup-failed`, never a hang. */
  readonly timeoutMs?: number | undefined;
  /** Deadline scheduler — injected so tests fire it by hand (no real timer). */
  readonly scheduler?: Scheduler | undefined;
  /** The session account, used ONLY to stamp a REDACTED marker on diagnostics (as the transport
   * redacts it). The full id never reaches a message. */
  readonly account?: string | undefined;
  /** Redacted-diagnostic sink (default no-op). Receives only already-redacted strings. */
  readonly log?: ((line: string) => void) | undefined;
}

/** Which underlier + class an option chain is being pulled for. */
export interface IbkrChainQuery {
  readonly symbol: string;
  /** The underlier's IB conId — resolve the underlier ({@link resolveUnderlier}) first, then hand its
   * conId here. */
  readonly underlyingConId: number;
  /** What the underlier IS. `reqSecDefOptParams` takes the underlier's own secType, and an INDEX
   * underlier asked about as `STK` returns NO chain at all — the NDX/XND blocker. Defaults to `STK`,
   * which is what every equity/ETF caller has always meant; an index caller must say `IND`. Not a
   * guess in either direction: the caller states it (same rule as {@link resolveUnderlier}). */
  readonly underlyingSecType?: UnderlierSecType | undefined;
  /** Pin a trading class; absent + several classes on the exchange ⇒ a typed `ambiguous` refusal. */
  readonly tradingClass?: string | undefined;
}

// ─────────────────────────────────────────────────────────────────────────────
// resolveContract — the seam.
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Map a resolved {@link OrderIntent} onto exactly ONE broker contract (kestrel-7o2.6).
 *
 * Routing is ADR-0017's rule and nothing else: **strike AND right both present ⇒ an OPTION; both
 * absent ⇒ a SPOT/EQUITY**. Anything in between is not an instrument identity and is refused before
 * the gateway is even asked (a right is never guessed from a side; a strike is never guessed from a
 * price — indeed no price is ever read).
 *
 * The resolved contract is the GATEWAY's own definition: its conId, its OCC local symbol, its
 * multiplier, its exchange, its currency, its expiry. Zero, several, incomplete, or unanswered ⇒ a
 * LOUD {@link IbkrContractError} the caller logs and STANDs DOWN on.
 *
 * @throws {IbkrContractError} `unsupported` | `unresolvable` | `ambiguous` | `lookup-failed`.
 */
export async function resolveContract(
  intent: OrderIntent,
  deps: IbkrContractDeps,
): Promise<IbkrContract> {
  const hasStrike = intent.strike !== undefined;
  const hasRight = intent.right !== undefined;
  const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
  const currency = deps.currency ?? IBKR_DEFAULT_CURRENCY;

  // ADR-0017's routing rule, enforced as a GATE. A half-specified leg is not an instrument.
  if (hasStrike !== hasRight) {
    throw refuse(
      deps,
      "unsupported",
      `leg ${describeLeg(intent)} specifies ${hasStrike ? "a strike with NO right" : "a right with NO strike"} — an option identity needs BOTH (ADR-0017), and neither is ever invented`,
    );
  }

  // A spot ORDER leg is an EQUITY and only ever an equity. An index (`IND`) is not tradable, so the
  // order path does not route to it — its resolver is {@link resolveUnderlier}, whose return type
  // this function's does not include. Nothing to guard: the type is the guard.
  if (!hasStrike) return resolveEquity(intent.instrument, describeLeg(intent), deps, exchange, currency);

  const expiry = nonEmpty(deps.expiry);
  if (expiry === undefined) {
    throw refuse(
      deps,
      "unsupported",
      `option leg ${describeLeg(intent)} has NO supplied expiry — the contract layer never guesses an expiry (a "nearest" one is a trade nobody authored)`,
    );
  }

  const strike = intent.strike as number;
  const right = intent.right as Right;
  const query: Contract = {
    symbol: intent.instrument,
    secType: SecType.OPT,
    exchange,
    currency,
    lastTradeDateOrContractMonth: expiry,
    strike,
    right: right === "C" ? OptionType.Call : OptionType.Put,
    ...(deps.tradingClass === undefined ? {} : { tradingClass: deps.tradingClass }),
  };

  const details = await requestDefinitions(deps, query, describeLeg(intent, expiry));
  const matches = details.filter((d) =>
    matchesOption(d.contract, intent.instrument, expiry, strike, right, deps.tradingClass),
  );
  const only = exactlyOne(deps, matches, describeLeg(intent, expiry));
  return buildOption(deps, only.contract, describeLeg(intent, expiry));
}

async function resolveEquity(
  symbol: string,
  label: string,
  deps: IbkrContractDeps,
  exchange: string,
  currency: string,
): Promise<IbkrEquityContract> {
  // A spot leg writes NO fictional strike/right onto the wire (ADR-0017) and needs no expiry.
  const query: Contract = {
    symbol,
    secType: SecType.STK,
    exchange,
    currency,
  };
  const details = await requestDefinitions(deps, query, label);
  const matches = details.filter(
    (d) => d.contract.secType === SecType.STK && d.contract.symbol === symbol,
  );
  const only = exactlyOne(deps, matches, label);
  const c = only.contract;
  const conId = c.conId;
  const localSymbol = nonEmpty(c.localSymbol);
  const venueExchange = nonEmpty(c.exchange) ?? exchange;
  const venueCurrency = nonEmpty(c.currency);
  if (conId === undefined || localSymbol === undefined || venueCurrency === undefined) {
    throw refuse(
      deps,
      "unresolvable",
      `the gateway's definition for ${label} is incomplete (conId/localSymbol/currency missing) — a partial definition is never completed by hand`,
    );
  }
  return {
    kind: "equity",
    secType: "STK",
    conId,
    symbol,
    exchange: venueExchange,
    primaryExchange: nonEmpty(c.primaryExch),
    currency: venueCurrency,
    localSymbol,
    tradingClass: nonEmpty(c.tradingClass),
    multiplier: 1,
  };
}

/**
 * Resolve the UNDERLIER a chain hangs off / a feed's spot line reads (kestrel-7o2.24) — an equity/ETF
 * (`STK`) or an **index** (`IND`, the NDX/XND/SPX class of cash-settled index options).
 *
 * `secType` is REQUIRED and never inferred. A symbol does not say what it is: `XND` is an index at
 * Nasdaq and could be an ETF's ticker anywhere else, and an adapter that sniffed the class would be
 * guessing an identity — precisely what this layer exists not to do. The caller states it; the
 * gateway's definition confirms it, or the lookup refuses.
 *
 * The result is an {@link IbkrUnderlierContract}, which is NOT an {@link IbkrContract}: an index is
 * not tradable and the type says so, so nothing resolved here can be handed to the order path.
 *
 * ## EXCHANGE: pinned, or left OPEN — never defaulted to SMART for an index
 * An equity is SMART-routed and defaults to {@link IBKR_DEFAULT_EXCHANGE} as it always has. An index
 * is NOT: `SMART` is a router, and indices are published by their listing venue (NDX on `NASDAQ`, SPX
 * on `CBOE`). So for `IND` the query's exchange is the caller's pin if there is one, and otherwise
 * LEFT OPEN — asking the venue "which listings of this index do you have?" rather than asserting a
 * wrong one. Open + exactly one listing ⇒ resolved. Open + several ⇒ a LOUD `ambiguous` refusal that
 * NAMES the exchanges, so the operator learns exactly what to pin. Both are fail-closed; the open
 * query is merely the one that fails INFORMATIVELY instead of as a flat `unresolvable`.
 *
 * @throws {IbkrContractError} `unresolvable` | `ambiguous` | `lookup-failed`.
 */
export async function resolveUnderlier(
  query: { readonly symbol: string; readonly secType: UnderlierSecType },
  deps: IbkrContractDeps,
): Promise<IbkrUnderlierContract> {
  const currency = deps.currency ?? IBKR_DEFAULT_CURRENCY;
  if (query.secType === "STK") {
    const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
    return resolveEquity(query.symbol, `${query.symbol} (spot/equity)`, deps, exchange, currency);
  }
  return resolveIndex(query.symbol, deps, currency);
}

/** The `IND` half of {@link resolveUnderlier}. Same fail-closed spine as every other lookup: the
 * gateway's OWN definition, exactly one match or a typed refusal, no field hand-completed. */
async function resolveIndex(
  symbol: string,
  deps: IbkrContractDeps,
  currency: string,
): Promise<IbkrIndexContract> {
  // Pinned, or OPEN — never SMART (see resolveUnderlier's docblock). `tradingClass` is deliberately
  // NOT forwarded: a trading class names an OPTION series (`XND`, `SPXW`), not an index, and putting
  // an option's class on an index query asks for a contract that does not exist.
  const pinned = nonEmpty(deps.exchange);
  const label = `${symbol} (index/IND)${pinned === undefined ? " [exchange OPEN]" : `@${pinned}`}`;
  const query: Contract = {
    symbol,
    secType: SecType.IND,
    currency,
    ...(pinned === undefined ? {} : { exchange: pinned }),
  };
  const details = await requestDefinitions(deps, query, label);
  // The secType filter is load-bearing, not decoration: an `IND` question answered with a `STK`
  // definition (a same-ticker ETF) is a DIFFERENT instrument, and accepting it would silently swap
  // the thing the strike window centres on.
  const matches = details.filter(
    (d) =>
      d.contract.secType === SecType.IND &&
      nonEmpty(d.contract.symbol) === symbol &&
      (pinned === undefined || nonEmpty(d.contract.exchange) === pinned),
  );
  const only = exactlyOne(deps, matches, label);
  const c = only.contract;
  const conId = c.conId;
  const localSymbol = nonEmpty(c.localSymbol);
  const venueExchange = nonEmpty(c.exchange);
  const venueCurrency = nonEmpty(c.currency);
  if (
    conId === undefined ||
    localSymbol === undefined ||
    venueExchange === undefined ||
    venueCurrency === undefined
  ) {
    throw refuse(
      deps,
      "unresolvable",
      `the gateway's definition for ${label} is incomplete (conId/localSymbol/exchange/currency missing) — a partial definition is never completed by hand`,
    );
  }
  return {
    kind: "index",
    secType: "IND",
    conId,
    symbol,
    exchange: venueExchange,
    currency: venueCurrency,
    localSymbol,
    tradingClass: nonEmpty(c.tradingClass),
  };
}

/** Build the option contract from the gateway's ONE matching definition. Every field is READ from
 * the venue — an incomplete definition is refused, never hand-completed (no hand-rolled OCC symbol,
 * no assumed 100x multiplier). No price is read: `minTick`, `marketName`, and every other decoration
 * on the {@link ContractDetails} is deliberately dropped on the floor. */
function buildOption(deps: IbkrContractDeps, c: Contract, leg: string): IbkrOptionContract {
  const conId = c.conId;
  const localSymbol = nonEmpty(c.localSymbol);
  const multiplier = numberish(c.multiplier);
  const expiry = expiryOf(c); // the venue decorates its own date — normalise, never re-format by hand
  const strike = numberish(c.strike);
  const right = rightOf(c.right);
  const currency = nonEmpty(c.currency);
  const exchange = nonEmpty(c.exchange);
  if (
    conId === undefined ||
    localSymbol === undefined ||
    multiplier === undefined ||
    expiry === undefined ||
    strike === undefined ||
    right === undefined ||
    currency === undefined ||
    exchange === undefined
  ) {
    throw refuse(
      deps,
      "unresolvable",
      `the gateway's definition for ${leg} is incomplete (missing conId/localSymbol/multiplier/expiry/strike/right/currency/exchange) — a partial definition is never completed by hand`,
    );
  }
  return {
    kind: "option",
    secType: "OPT",
    conId,
    symbol: nonEmpty(c.symbol) ?? "",
    exchange,
    currency,
    localSymbol,
    tradingClass: nonEmpty(c.tradingClass),
    multiplier,
    expiry,
    strike,
    right,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// resolveOptionChain — the gateway's own strike/expiry grid (a definition lookup, not a quote).
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Pull the gateway's option-chain parameters for an underlier (kestrel-7o2.6) — the expirations, the
 * strike grid, and the multiplier IB itself publishes for one exchange + trading class. This is a
 * DEFINITION lookup (`reqSecDefOptParams`), not market data: it carries no quote and no price.
 *
 * A caller uses it to pick a strike/expiry from what the venue actually lists (rather than inventing
 * one) and then hands that identity back to {@link resolveContract}. Fail-closed: an empty chain is
 * `unresolvable`; several trading classes on the requested exchange with none pinned is `ambiguous`
 * (never a pick — two classes are two instruments).
 *
 * @throws {IbkrContractError} `unresolvable` | `ambiguous` | `lookup-failed`.
 */
export async function resolveOptionChain(
  query: IbkrChainQuery,
  deps: IbkrContractDeps,
): Promise<IbkrOptionChain> {
  const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
  const wanted = query.tradingClass ?? deps.tradingClass;
  const label = `option chain for ${query.symbol}@${exchange}${wanted === undefined ? "" : ` class=${wanted}`}`;

  const rows = await requestChain(deps, query, label);
  const onExchange = rows.filter(
    (r) => r.exchange === exchange && (wanted === undefined || r.tradingClass === wanted),
  );
  if (onExchange.length === 0) {
    throw refuse(
      deps,
      "unresolvable",
      `the gateway lists NO ${label} — an expiry/strike grid is never invented`,
      { candidates: 0 },
    );
  }
  if (onExchange.length > 1) {
    throw refuse(
      deps,
      "ambiguous",
      `the gateway lists ${onExchange.length} trading classes for ${label} (${onExchange.map((r) => r.tradingClass).join(", ")}) — ambiguous; pin one with \`tradingClass\` rather than have the layer pick`,
      { candidates: onExchange.length },
    );
  }
  const row = onExchange[0] as ChainRow;
  const multiplier = numberish(row.multiplier);
  if (multiplier === undefined) {
    throw refuse(deps, "unresolvable", `the ${label} came back with no multiplier — incomplete`);
  }
  return {
    symbol: query.symbol,
    exchange: row.exchange,
    tradingClass: row.tradingClass,
    multiplier,
    // Sorted so the same gateway reply always yields the same grid, whatever order the socket
    // delivered it in (determinism at the edge — a caller's "nearest expiry" pick must be stable).
    expirations: [...row.expirations].sort(),
    strikes: [...row.strikes].sort((a, b) => a - b),
  };
}

/**
 * The strikes the gateway ACTUALLY LISTS for one expiry + right (kestrel-7o2.6) — a definition
 * lookup (`reqContractDetails` with the strike left OPEN), not market data: no quote, no price.
 *
 * Why it exists beside {@link resolveOptionChain}: `reqSecDefOptParams` returns the UNION of strikes
 * across every expiry of an underlier, so a strike from that grid may not be listed on the expiry a
 * caller actually wants (the near-dated series is far narrower than the union — verified against the
 * live gateway). Picking from the union would therefore invent an identity the venue does not list.
 * This asks the venue what it lists for THAT expiry, so a caller picks from the truth.
 *
 * @throws {IbkrContractError} `unresolvable` (the venue lists no strike for it) | `lookup-failed`.
 */
export async function listOptionStrikes(
  query: { symbol: string; expiry: string; right: Right; tradingClass?: string | undefined },
  deps: IbkrContractDeps,
): Promise<readonly number[]> {
  const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
  const currency = deps.currency ?? IBKR_DEFAULT_CURRENCY;
  const tradingClass = query.tradingClass ?? deps.tradingClass;
  const label = `listed ${query.symbol} ${query.expiry} ${query.right} strikes@${exchange}`;
  // The strike is deliberately LEFT OPEN — that is the whole request: "which strikes do you list?"
  const open: Contract = {
    symbol: query.symbol,
    secType: SecType.OPT,
    exchange,
    currency,
    lastTradeDateOrContractMonth: query.expiry,
    right: query.right === "C" ? OptionType.Call : OptionType.Put,
    ...(tradingClass === undefined ? {} : { tradingClass }),
  };
  const details = await requestDefinitions(deps, open, label);
  const strikes = new Set<number>();
  for (const d of details) {
    const c = d.contract;
    if (c.secType !== SecType.OPT) continue;
    if (nonEmpty(c.symbol) !== query.symbol) continue;
    if (expiryOf(c) !== query.expiry) continue;
    if (rightOf(c.right) !== query.right) continue;
    if (tradingClass !== undefined && nonEmpty(c.tradingClass) !== tradingClass) continue;
    const s = numberish(c.strike);
    if (s !== undefined) strikes.add(s);
  }
  if (strikes.size === 0) {
    throw refuse(
      deps,
      "unresolvable",
      `the gateway lists NO strikes for ${label} — a strike grid is never invented`,
      { candidates: 0 },
    );
  }
  return [...strikes].sort((a, b) => a - b);
}

// ─────────────────────────────────────────────────────────────────────────────
// The shared-session bridge.
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The ONE shared IB session, viewed through the READ-ONLY definition surface (kestrel-7o2.6). The
 * transport hands out its single guarded client (throwing the typed connection error if the session
 * is not connected or has gone degraded, so a contract can never be resolved over a dead socket);
 * this re-views it as an {@link IbContractClient}. The cast NARROWS toward the two definition
 * requests — the real `IBApi` is a structural superset of both views, and neither view can submit an
 * order. There is never a second socket.
 */
export function contractClientOf(transport: IbkrTransport): IbContractClient {
  return transport.client() as unknown as IbContractClient;
}

// ─────────────────────────────────────────────────────────────────────────────
// Internals — the request/response plumbing (listeners, deadline, teardown).
// ─────────────────────────────────────────────────────────────────────────────

interface ChainRow {
  readonly exchange: string;
  readonly tradingClass: string;
  readonly multiplier: string;
  readonly expirations: readonly string[];
  readonly strikes: readonly number[];
}

/** Ask the gateway for every definition matching `query`, collecting until `contractDetailsEnd`. A
 * fatal IB error on OUR request id, or a deadline lapse, is a typed refusal — never a hang and never
 * an empty-result-by-silence (which would look like "no such contract" when the socket merely died). */
function requestDefinitions(
  deps: IbkrContractDeps,
  query: Contract,
  label: string,
): Promise<ContractDetails[]> {
  return request<ContractDetails[]>(deps, label, (reqId, ctx) => {
    const collected: ContractDetails[] = [];
    ctx.listen(EventName.contractDetails, (id: number, details: ContractDetails) => {
      if (id === reqId) collected.push(details);
    });
    ctx.listen(EventName.contractDetailsEnd, (id: number) => {
      if (id === reqId) ctx.done(collected);
    });
    deps.client.reqContractDetails(reqId, query);
  });
}

/** Ask the gateway for an underlier's chain parameters, collecting until the end marker. */
function requestChain(
  deps: IbkrContractDeps,
  query: IbkrChainQuery,
  label: string,
): Promise<ChainRow[]> {
  return request<ChainRow[]>(deps, label, (reqId, ctx) => {
    const rows: ChainRow[] = [];
    ctx.listen(
      EventName.securityDefinitionOptionParameter,
      (
        id: number,
        exchange: string,
        _underlyingConId: number,
        tradingClass: string,
        multiplier: string,
        expirations: string[],
        strikes: number[],
      ) => {
        if (id === reqId) rows.push({ exchange, tradingClass, multiplier, expirations, strikes });
      },
    );
    ctx.listen(EventName.securityDefinitionOptionParameterEnd, (id: number) => {
      if (id === reqId) ctx.done(rows);
    });
    // `futFopExchange` is empty for an equity/ETF **and** an index underlier (IB's own convention —
    // it names a FUT/FOP exchange, which neither is). The underlier's secType is the caller's
    // (`STK` for an equity/ETF, `IND` for an index — an index asked about as STK returns NOTHING),
    // and its conId is the caller's already-resolved underlier contract.
    const underlyingSecType = query.underlyingSecType === "IND" ? SecType.IND : SecType.STK;
    deps.client.reqSecDefOptParams(reqId, query.symbol, "", underlyingSecType, query.underlyingConId);
  });
}

/** The collector handed to a request body: register a listener (auto-torn-down) and settle. */
interface RequestCtx<T> {
  listen<A extends unknown[]>(event: EventName, listener: (...args: A) => void): void;
  done(value: T): void;
}

/**
 * The ONE request/response shape both definition lookups share: mint a request id, wire the reply +
 * error + deadline listeners, run the request, and ALWAYS tear every listener back off (on success
 * and on failure alike — a leaked listener on a shared socket is a cross-request bug). Non-fatal IB
 * info codes (2100–2999, market-data-farm chatter) are ignored, exactly as the transport ignores
 * them; a FATAL error on our own request id refuses, typed.
 */
function request<T>(
  deps: IbkrContractDeps,
  label: string,
  body: (reqId: number, ctx: RequestCtx<T>) => void,
): Promise<T> {
  const client = deps.client;
  const scheduler = deps.scheduler ?? defaultScheduler;
  const timeoutMs = deps.timeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
  const reqId = (deps.nextReqId ?? defaultNextReqId)();

  return new Promise<T>((resolve, reject) => {
    const registered: Array<[EventName, IbListener]> = [];
    let settled = false;
    let deadline: TimerHandle | undefined;

    const teardown = (): void => {
      for (const [event, listener] of registered) client.removeListener(event, listener);
      registered.length = 0;
      if (deadline !== undefined) {
        scheduler.clearInterval(deadline);
        deadline = undefined;
      }
    };
    const settle = (fn: () => void): void => {
      if (settled) return;
      settled = true;
      teardown();
      fn();
    };

    const ctx: RequestCtx<T> = {
      listen: <A extends unknown[]>(event: EventName, listener: (...args: A) => void): void => {
        const wrapped = listener as unknown as IbListener;
        client.on(event, wrapped);
        registered.push([event, wrapped]);
      },
      done: (value: T): void => {
        settle(() => {
          resolve(value);
        });
      },
    };

    ctx.listen(EventName.error, (err: Error, code: number, id: number) => {
      if (id !== reqId) return; // another face's request on the shared socket — not ours to judge
      if (isNonFatalError(code, err)) {
        deps.log?.(`ib info during ${label} (code=${code})`);
        return;
      }
      settle(() => {
        reject(
          code === IB_NO_SECURITY_DEFINITION
            ? refuse(
                deps,
                "unresolvable",
                `the gateway has NO security definition for ${label} (IB 200) — an unknown strike/right/expiry/symbol is never rounded toward a nearby one`,
                { code, cause: err, candidates: 0 },
              )
            : refuse(deps, "lookup-failed", `the definition lookup for ${label} failed at the gateway (IB ${code})`, {
                code,
                cause: err,
              }),
        );
      });
    });

    // The deadline: a gateway that never answers is a FAILED lookup, not an empty one. Fired through
    // the injected scheduler so tests trip it by hand (no real timer, no flake).
    deadline = scheduler.setInterval(() => {
      settle(() => {
        reject(
          refuse(deps, "lookup-failed", `the definition lookup for ${label} timed out after ${timeoutMs}ms`),
        );
      });
    }, timeoutMs);

    try {
      body(reqId, ctx);
    } catch (cause) {
      settle(() => {
        reject(refuse(deps, "lookup-failed", `the definition request for ${label} threw at the socket`, { cause }));
      });
    }
  });
}

/** Exactly one definition, or a typed refusal. The heart of the fail-closed doctrine: ZERO is
 * `unresolvable`, MORE THAN ONE is `ambiguous` — and the layer NEVER breaks the tie itself. */
function exactlyOne(
  deps: IbkrContractDeps,
  matches: readonly ContractDetails[],
  label: string,
): ContractDetails {
  if (matches.length === 0) {
    throw refuse(
      deps,
      "unresolvable",
      `the gateway matched NO definition for ${label} — an unknown strike/right/expiry/symbol never becomes a nearby guess`,
      { candidates: 0 },
    );
  }
  if (matches.length > 1) {
    throw refuse(
      deps,
      "ambiguous",
      `the gateway matched ${matches.length} definitions for ${label} (${matches
        .map((m) => describeCandidate(m.contract))
        .join(" | ")}) — ambiguous, and picking one of several would route an order to a contract nobody authored; pin \`tradingClass\`/\`exchange\` instead`,
      { candidates: matches.length },
    );
  }
  return matches[0] as ContractDetails;
}

/** Build + log the typed refusal. The reason is already secret-free; the account (if any) is stamped
 * REDACTED, as the transport stamps it, so an operator recognises their own session without the id
 * ever reaching a log. */
function refuse(
  deps: IbkrContractDeps,
  failure: "unsupported" | "unresolvable" | "ambiguous" | "lookup-failed",
  reason: string,
  options: { code?: number | undefined; candidates?: number | undefined; cause?: unknown } = {},
): IbkrContractError {
  const stamped = `${reason} [account=${redactAccount(deps.account)}]`;
  deps.log?.(`contract refused [${failure}]: ${stamped}`);
  return new IbkrContractError(failure, stamped, options);
}

/** A secret-free, publication-safe description of the leg being resolved (generic symbol + identity
 * only — no price, ever). */
function describeLeg(intent: OrderIntent, expiry?: string): string {
  if (intent.strike === undefined || intent.right === undefined) {
    return intent.strike === undefined && intent.right === undefined
      ? `${intent.instrument} (spot/equity)`
      : `${intent.instrument} strike=${intent.strike ?? "-"} right=${intent.right ?? "-"}`;
  }
  return `${intent.instrument} ${expiry ?? "(no expiry)"} ${intent.strike}${intent.right}`;
}

/** How a single ambiguous candidate is named in the refusal — enough for an operator to see WHY the
 * two differ, never enough to constitute a pick. */
function describeCandidate(c: Contract): string {
  return `conId=${c.conId ?? "?"} class=${c.tradingClass ?? "?"} exch=${c.exchange ?? "?"}/${c.primaryExch ?? "-"} local=${c.localSymbol ?? "?"}`;
}

/**
 * Does the gateway's definition actually ANSWER the identity we asked about? A defence-in-depth
 * filter: a definition that drifts from the requested strike/right/expiry/symbol is not a match, and
 * a near-miss is never quietly accepted as one.
 *
 * ## The tradingClass pin is ENFORCED here, not merely REQUESTED on the wire
 * A pinned `tradingClass` rides on the query, but a pin that only rides on the query is a pin the
 * GATEWAY may ignore — and it does: `reqContractDetails` is a match-what-you-can lookup, and for a
 * multi-class underlier (`NDX` with `NDX` a.m.- and `NDXP` p.m.-settled; `SPX` with `SPXW`) IB has
 * been observed answering with every class on the strike. Filtering here is what makes the pin the
 * disambiguator {@link IbkrContractDeps.tradingClass} claims to be. Without it the pin is INERT: the
 * two classes both survive to {@link exactlyOne} and the lookup refuses `ambiguous` — fail-closed,
 * but the caller did everything right and still cannot resolve the leg, so XND/SPXW is unreachable.
 * (This mirrors {@link listOptionStrikes}, which has always filtered on the class.)
 *
 * Note the direction: this only ever REMOVES candidates. It cannot turn an ambiguity into a pick —
 * two definitions sharing the pinned class are still two definitions, and still refuse.
 */
function matchesOption(
  c: Contract,
  symbol: string,
  expiry: string,
  strike: number,
  right: Right,
  tradingClass: string | undefined,
): boolean {
  if (c.secType !== SecType.OPT) return false;
  if (nonEmpty(c.symbol) !== symbol) return false;
  if (expiryOf(c) !== expiry) return false;
  if (tradingClass !== undefined && nonEmpty(c.tradingClass) !== tradingClass) return false;
  const gotStrike = numberish(c.strike);
  if (gotStrike === undefined || Math.abs(gotStrike - strike) > 1e-9) return false;
  return rightOf(c.right) === right;
}

/**
 * The venue's LAST TRADING DAY as a bare `YYYYMMDD` (kestrel-7o2.6).
 *
 * A real-gateway fact the live acceptance caught: IB ANSWERS with a decorated
 * `lastTradeDateOrContractMonth` — `"20260714 16:15:00 US/Eastern"` — even though it is QUERIED with
 * the bare `"20260714"`. It also carries the clean date separately as `lastTradeDate`. Normalising
 * here is what lets the identity check stay STRICT (a definition whose expiry drifts from the one
 * asked for is not a match) without a string-format artefact rejecting every real definition.
 *
 * A 6-digit contract MONTH (`YYYYMM`, futures-style) is deliberately NOT accepted: we asked for a
 * day, so a month is not an answer to the question — it fails closed as an incomplete definition.
 */
function expiryOf(c: Contract): string | undefined {
  const raw = nonEmpty(c.lastTradeDate) ?? nonEmpty(c.lastTradeDateOrContractMonth);
  if (raw === undefined) return undefined;
  const head = raw.split(/\s+/)[0];
  return head !== undefined && /^\d{8}$/.test(head) ? head : undefined;
}

/** IB delivers the right as `C`/`P` (and, on some paths, `CALL`/`PUT`). Anything else is UNKNOWN —
 * `undefined`, which fails closed upstream rather than defaulting to a call. */
function rightOf(right: unknown): Right | undefined {
  if (typeof right !== "string") return undefined;
  const r = right.trim().toUpperCase();
  if (r === "C" || r === "CALL") return "C";
  if (r === "P" || r === "PUT") return "P";
  return undefined;
}

/** IB's wire types are loose (a multiplier arrives as `"100"` on some paths and `100` on others).
 * Normalise to a finite number, or `undefined` — never `NaN`, never a coerced zero. */
function numberish(v: unknown): number | undefined {
  if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
  if (typeof v === "string") {
    const t = v.trim();
    if (t.length === 0) return undefined;
    const n = Number(t);
    return Number.isFinite(n) ? n : undefined;
  }
  return undefined;
}

function nonEmpty(v: string | undefined): string | undefined {
  if (v === undefined) return undefined;
  const t = v.trim();
  return t.length > 0 ? t : undefined;
}

/** The default request-id minter — a process-local monotone counter well clear of the transport's
 * order-id space. A counter, not an RNG: the edge may be async, but it is never random. */
let reqIdCounter = 9_000;
const defaultNextReqId = (): number => ++reqIdCounter;

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