import type {
  ComposeCompilePartialData,
  ComposeCompileRequest,
  ComposeCompileResult,
  ComposeCompileSuccessData,
  ComposeManifest,
  ComposeRouteRequest,
  SimulateRequest,
  SimulateResult,
} from '@lifi/compose-spec';
import {
  checkCompatibility,
  COMPOSE_VERSION_HEADER,
  COMPOSER_SDK_VERSION_HEADER,
  parseVersion,
} from '@lifi/compose-spec';

import type {
  ChainSummary,
  GetZapPacksOptions,
  ZapPackOverview,
} from './discovery.js';
import { ComposeError, errorFromHttpResponse } from './errors.js';
import {
  parseCompilePartialEnvelope,
  parseSimulateResult,
} from './responseSchemas.js';

// __SDK_VERSION__ is a compile-time constant injected by tsup (via `define` in tsup.config.ts)
// and by vitest (via `define` in vitest.config.ts). Both read the version from package.json
// at build/test time and replace this identifier with the literal string value.
// It is sent as the `COMPOSER_SDK_VERSION_HEADER` request header so the server can identify the caller.
// Falls back to 'dev' when running via tsx without tsup substitution (e.g. the example harness).
declare const __SDK_VERSION__: string;

const SDK_VERSION: string =
  typeof __SDK_VERSION__ !== 'undefined' ? __SDK_VERSION__ : 'dev';
// `undefined` for the `dev` sentinel, which disables the response version check.
const SDK_VERSION_TRIPLE = parseVersion(SDK_VERSION);

/**
 * Configuration for creating a low-level Compose API client.
 */
export interface ComposeClientOptions {
  /** Base URL of the Compose API. */
  readonly baseUrl: string;
  /** Optional custom `fetch` implementation. Defaults to `globalThis.fetch`. */
  readonly fetch?: typeof globalThis.fetch;
  /** LI.FI API key, sent as the `x-lifi-api-key` header on every request. Required — the Compose API rejects unauthenticated requests. */
  readonly apiKey: string;
}

/**
 * Low-level HTTP client for the Compose API.
 *
 * Handles request serialization, SDK version headers, and error mapping.
 * Prefer using {@link ComposeSdk} for the full builder experience. Use this
 * directly when you need to decouple request building from submission — e.g.
 * build via `sdk.request()` then submit via `client.compile()` with custom
 * retry logic or request inspection.
 */
export interface ComposeClient {
  /**
   * Fetches the server's operation manifest describing all supported operations,
   * guards, materialisers, and preconditions.
   * @returns The manifest document.
   * @throws {@link ComposeError} on network, validation, or server errors.
   */
  readonly getManifest: () => Promise<ComposeManifest>;
  /**
   * Fetches the chains the Compose API supports, ascending by `chainId`.
   *
   * A chain is listed once the server confirms the VM contract is deployed on
   * it — the same condition {@link compile}, {@link route} and
   * {@link simulate} validate against. A chain absent from this list is
   * rejected outright by those endpoints.
   *
   * The converse does not hold: a listed chain can still have no routing edge
   * for a given token pair, which {@link route} reports as `no_route_error`.
   * Use {@link getZapPacks} to enumerate the pairs that do route.
   *
   * Each entry carries a display `name` alongside its `chainId`. The name is a
   * deployment-maintained label — render it, but key on `chainId`.
   *
   * The list tracks the server's chain registry, which refreshes periodically.
   * Results are not cached by the SDK; callers should cache as appropriate.
   *
   * @returns The supported chains, ascending by `chainId`.
   * @throws {@link ComposeError} on network or server errors.
   */
  readonly getChains: () => Promise<readonly ChainSummary[]>;
  /**
   * Submits a compile request and returns the result.
   *
   * When the caller passes `simulationPolicy: 'allow-revert'` and the transaction
   * reverts in simulation, the server responds with HTTP 206 and the SDK returns a
   * partial result (`status: 'partial'`) instead of throwing. The partial result
   * includes the transaction (without `gasLimit`) and revert diagnostics.
   *
   * @param request - The full compile request including flow and run inputs.
   * @returns A discriminated result: `status: 'success'` or `status: 'partial'`.
   * @throws {@link ComposeError} on network, validation, or server errors.
   */
  readonly compile: (
    request: ComposeCompileRequest,
  ) => Promise<ComposeCompileResult>;
  /**
   * Compiles a token pair into a submit-ready transaction via
   * `POST /compose/route`.
   *
   * A convenience path that skips flow authoring: the server builds a one-node
   * zap flow with a `directDeposit` input from the `fromToken` / `toToken`
   * pair and runs it through the same pipeline as {@link compile}. The
   * response is therefore the same discriminated result — HTTP 206 under
   * `simulationPolicy: 'allow-revert'` yields `status: 'partial'` rather than
   * throwing, exactly as it does for {@link compile}.
   *
   * As of today that flow is a single zap step: one edge of the backend's
   * routing catalog (a position entry or exit, a wrap or unwrap, a mint or
   * burn). Enumerate the pairs it covers with {@link getZapPacks}; a pair with
   * no edge is rejected with `kind: 'no_route_error'`. The endpoint is meant to
   * author richer flows later, so the single-step limit is the current state of
   * the server, not the shape of this contract.
   *
   * Only synchronous edges are considered unless the request sets
   * `allowAsyncRoutes: true`, so by default every terminal output comes back
   * `delivery.when: 'now'` and a pair reachable only after settlement is a
   * `no_route_error`. Opting in adds the `lifi.zapAsync` fallback, which still
   * loses to a synchronous edge when the pair has one; read
   * `outputs[*].delivery.when` to see which representation you got.
   *
   * Use {@link compile} for what the server does not author for you: several
   * chained operations, splits, explicit preconditions, or per-op guards.
   *
   * `bigint` amounts in `amount` are serialised to decimal strings
   * automatically.
   *
   * @param request - The from/to token pair, amount, signer, and route options.
   * @returns A discriminated result: `status: 'success'` or `status: 'partial'`.
   * @throws {@link ComposeError} on network, validation, or server errors —
   *   including `NOT_FOUND` (HTTP 404) when no catalog edge covers the pair.
   */
  readonly route: (
    request: ComposeRouteRequest,
  ) => Promise<ComposeCompileResult>;
  /**
   * Fetches the available routing edges grouped by protocol.
   *
   * The edge catalog is dynamic — it reflects the current state of the
   * backend's routing snapshot (protocols, chains, token blacklists).
   * Results are not cached by the SDK; callers should cache as appropriate.
   *
   * @param options - Optional filter to restrict results to specific protocols.
   * @returns An array of {@link ZapPackOverview} objects, one per protocol.
   * @throws {@link ComposeError} on network or server errors (503 when the
   *   routing catalog is not yet initialized).
   */
  readonly getZapPacks: (
    options?: GetZapPacksOptions,
  ) => Promise<readonly ZapPackOverview[]>;
  /**
   * Simulates a raw, pre-encoded transaction against `POST /simulate` and
   * reports how the watched balances change and how much inner-call gas it
   * burns.
   *
   * The result is a discriminated union on `status`:
   * - `'ok'` — the simulation ran successfully; `deltas`/`gasUsed` are populated.
   * - `'revert'` — the simulation ran but the transaction reverted on-chain. A
   *   revert is a *successful simulation*, not a transport error, so it is
   *   returned (HTTP 200) rather than thrown — mirroring how {@link compile}
   *   returns `status: 'partial'` on a simulated revert.
   * - `'error'` — the request was well-formed but the simulation could not be
   *   set up or run (HTTP 422); `message` is intentionally generic.
   *
   * `bigint` amounts in the request (`value`, requirement `balance`/`allowance`)
   * are serialised to decimal strings automatically.
   *
   * @param request - The raw transaction plus funding `requirements` and the
   *   `trackedBalances` to watch.
   * @returns A {@link SimulateResult} (`ok` / `revert` / `error`).
   * @throws {@link ComposeError} on network failures, HTTP 400 (malformed
   *   input), 401/403 (auth), 404, 429, and 5xx.
   */
  readonly simulate: (request: SimulateRequest) => Promise<SimulateResult>;
}

/**
 * `JSON.stringify` replacer that renders `bigint` amounts as decimal strings.
 *
 * The compose wire carries token amounts as decimal strings, and `bigint` has
 * no JSON representation, so every payload this client sends goes through it.
 * Exported so the example harness prints a request exactly as it is sent.
 */
export const bigintReplacer = (_key: string, value: unknown): unknown =>
  typeof value === 'bigint' ? value.toString() : value;

const isNonNullObject = (v: unknown): v is Record<string, unknown> =>
  typeof v === 'object' && v !== null;

// Non-negative decimal integer string, as the server sends token amounts.
const INTEGER_STRING = /^\d+$/;

const decodeAmount = (value: unknown, path: string, url: string): bigint => {
  if (typeof value !== 'string' || !INTEGER_STRING.test(value)) {
    throw new ComposeError(
      'UNKNOWN_ERROR',
      `Malformed compose response: ${path} is ${
        value === undefined ? 'absent' : 'not an integer string'
      }`,
      { url },
    );
  }
  return BigInt(value);
};

// The two records this seam reads out of a compile-family response body. Each
// is `unknown` because the seam is what establishes its shape: it validates
// every field before writing a decoded `bigint` back over the wire string.
interface WireAmountBody {
  outputs?: unknown;
}

// One `Amount` as it arrives: each leaf is `unknown` until this seam has
// checked it, and is overwritten in place with the decoded `bigint`.
interface WireAmount {
  estimate?: unknown;
  minimum?: unknown;
}

const malformed = (path: string, what: string, url: string): ComposeError =>
  new ComposeError(
    'UNKNOWN_ERROR',
    `Malformed compose response: ${path} ${what}`,
    { url },
  );

// Decode one `Estimate` / `Minimum` leaf in place: `{ basis, value }` with
// `value` a decimal string on the wire and a `bigint` on `Amount`.
const decodeAmountLeaf = (
  amount: WireAmount,
  field: 'estimate' | 'minimum',
  path: string,
  url: string,
): void => {
  const leaf = amount[field];
  if (leaf === undefined) return;
  if (!isNonNullObject(leaf)) {
    throw malformed(`${path}.${field}`, 'is not an object', url);
  }
  leaf.value = decodeAmount(leaf.value, `${path}.${field}.value`, url);
};

// `outputs` is REQUIRED on every compile-family body and is the record a
// caller reads amounts from, so its structure is checked here — the body is
// cast, not schema-parsed — before each `value` leaf becomes a `bigint`.
const decodeOutputs = (data: WireAmountBody, url: string): void => {
  const outputs = data.outputs;
  if (!isNonNullObject(outputs)) {
    throw malformed(
      'outputs',
      outputs === undefined ? 'is absent' : 'is not an object',
      url,
    );
  }
  for (const key of Object.keys(outputs)) {
    const entry = outputs[key];
    if (!isNonNullObject(entry)) {
      throw malformed(`outputs.${key}`, 'is not an object', url);
    }
    const amount = entry.amount;
    if (!isNonNullObject(amount)) {
      throw malformed(
        `outputs.${key}.amount`,
        amount === undefined ? 'is absent' : 'is not an object',
        url,
      );
    }
    decodeAmountLeaf(amount, 'estimate', `outputs.${key}.amount`, url);
    decodeAmountLeaf(amount, 'minimum', `outputs.${key}.amount`, url);
  }
};

// THE decode seam for a compile-family response body: every amount the wire
// renders as a decimal string becomes the `bigint` its declared type promises.
// The amount record is handled here so the body is narrowed once — a
// non-object body is not this function's error to raise, since the enveloping
// parse already rejected it.
const decodeWireAmounts = (data: unknown, url: string): void => {
  if (!isNonNullObject(data)) return;
  decodeOutputs(data, url);
};

const parseBody = async <T>(res: Response, url: string): Promise<T> => {
  const body = await res.json().catch((_) => null);
  if (!isNonNullObject(body) || !('data' in body)) {
    throw new ComposeError('UNKNOWN_ERROR', 'Unexpected response format', {
      url,
    });
  }
  return body.data as T;
};

const parseCompileSuccessBody = async (
  res: Response,
  url: string,
): Promise<ComposeCompileResult> => {
  const data = await parseBody<ComposeCompileSuccessData>(res, url);
  decodeWireAmounts(data, url);
  return { ...data, status: 'success' as const };
};

const parsePartialBody = async (
  res: Response,
  url: string,
): Promise<ComposeCompileResult> => {
  const body = await res.json().catch((_) => null);
  const envelope = parseCompilePartialEnvelope(body);
  if (envelope === null) {
    throw new ComposeError(
      'UNKNOWN_ERROR',
      'Unexpected partial response format',
      { url },
    );
  }
  // `data` is validated as an object by the schema; compose-spec owns its full
  // shape as a hand-authored type, so we narrow it here rather than re-declaring
  // that type as a schema. `error` is fully validated — no cast needed.
  const data = envelope.data as ComposeCompilePartialData;
  decodeWireAmounts(data, url);
  return { ...data, status: 'partial' as const, error: envelope.error };
};

// `/simulate` is un-enveloped: the discriminated body (`{ status, ... }`) is at
// the top level, NOT wrapped in `{ data }` like `/compose`. So this reads the
// body directly and validates it against the simulate union rather than reusing
// `parseBody`.
const parseSimulateBody = async (
  res: Response,
  url: string,
): Promise<SimulateResult> => {
  const body = await res.json().catch((_) => null);
  const result = parseSimulateResult(body);
  if (result === null) {
    throw new ComposeError(
      'UNKNOWN_ERROR',
      'Unexpected simulate response format',
      { url },
    );
  }
  return result;
};
// `POST /compose` and `POST /compose/route` differ only in path and request
// shape: both return the enveloped success data on 200 and the partial
// envelope on 206, so they share one transport.
type ComposeTransport = {
  readonly fetchFn: typeof fetch;
  readonly baseHeaders: Record<string, string>;
};

// A patch bump is additive and a minor bump is breaking, so a 200 from a
// backend behind this SDK cannot be trusted: the newer flow this SDK authored
// may have compiled under the server's older semantics without raising an
// error. This covers the deploy window where the SDK is published to npm ahead
// of the backend rollout. Only the compile-family POSTs are checked; discovery
// GETs stay reachable from a mismatched SDK, as on the server. An absent header
// (a pre-gate backend, a header-stripping proxy) is accepted, mirroring the
// server's pass-through of a compile that carries no version header.
const assertServerVersion = (res: Response, url: string): void => {
  const serverVersion = res.headers.get(COMPOSE_VERSION_HEADER);
  if (serverVersion === null || SDK_VERSION_TRIPLE === undefined) return;
  const serverTriple = parseVersion(serverVersion);
  if (serverTriple === undefined) return;

  const compatibility = checkCompatibility(SDK_VERSION_TRIPLE, serverTriple);
  if (compatibility === 'compatible') return;

  const versions = { sdkVersion: SDK_VERSION, serverVersion };
  if (compatibility === 'sdk_outdated') {
    throw new ComposeError(
      'VALIDATION_ERROR',
      `composer-sdk ${SDK_VERSION} is older than the compose contract served at ${url} (${serverVersion}); upgrade @lifi/composer-sdk and @lifi/compose-spec`,
      {
        status: res.status,
        url,
        kind: 'sdk_outdated',
        sdkOutdated: {
          ...versions,
          minimumSdkVersion: `${serverTriple.major}.${serverTriple.minor}.0`,
        },
      },
    );
  }
  throw new ComposeError(
    'VALIDATION_ERROR',
    `composer-sdk ${SDK_VERSION} targets a newer compose contract than the server at ${url} serves (${serverVersion}); pin @lifi/composer-sdk and @lifi/compose-spec to ${serverVersion} or wait for the server rollout`,
    {
      status: res.status,
      url,
      kind: 'server_outdated',
      serverOutdated: versions,
    },
  );
};

type SendInit =
  | { readonly method: 'GET' }
  | { readonly method: 'POST'; readonly body: unknown };

// GETs on this API carry no body; POSTs carry a JSON one with `bigint` amounts
// rendered as decimal strings.
const requestInit = (
  baseHeaders: Record<string, string>,
  init: SendInit,
): RequestInit =>
  init.method === 'GET'
    ? { method: 'GET', headers: { ...baseHeaders } }
    : {
        method: 'POST',
        headers: { ...baseHeaders, 'Content-Type': 'application/json' },
        body: JSON.stringify(init.body, bigintReplacer),
      };

// One fetch path for every route: maps transport failures to `NETWORK_ERROR`.
// The response version check is not here — only the compile-family POSTs gate
// on it (see `postCompile`); discovery GETs and `/simulate` are ungated.
const send = async (
  { fetchFn, baseHeaders }: ComposeTransport,
  url: string,
  init: SendInit,
): Promise<Response> => {
  try {
    return await fetchFn(url, requestInit(baseHeaders, init));
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    throw new ComposeError('NETWORK_ERROR', message, { cause: err });
  }
};

const postCompile = async (
  transport: ComposeTransport,
  url: string,
  request: ComposeCompileRequest | ComposeRouteRequest,
): Promise<ComposeCompileResult> => {
  const res = await send(transport, url, { method: 'POST', body: request });
  assertServerVersion(res, url);
  if (res.status === 206) {
    return await parsePartialBody(res, url);
  }
  if (!res.ok) {
    const body = await res.text();
    throw errorFromHttpResponse(res.status, body, url);
  }
  return await parseCompileSuccessBody(res, url);
};

// Every enveloped GET on this API behaves identically: no body, no query
// semantics of its own, `{ data }` on success, an error envelope otherwise.
const getJson = async <T>(
  transport: ComposeTransport,
  url: string,
): Promise<T> => {
  const res = await send(transport, url, { method: 'GET' });
  if (!res.ok) {
    const body = await res.text();
    throw errorFromHttpResponse(res.status, body, url);
  }
  return await parseBody<T>(res, url);
};

/**
 * Creates a low-level Compose API client.
 *
 * @param options - Client configuration including the API base URL.
 * @returns A {@link ComposeClient} instance.
 */
export const createComposeClient = (
  options: ComposeClientOptions,
): ComposeClient => {
  if (!options.baseUrl || !/^https?:\/\//i.test(options.baseUrl)) {
    throw new ComposeError(
      'VALIDATION_ERROR',
      `Invalid baseUrl: expected an HTTP(S) URL, got "${options.baseUrl}"`,
    );
  }
  const trimmedApiKey = options.apiKey?.trim() || undefined;
  if (!trimmedApiKey) {
    throw new ComposeError(
      'VALIDATION_ERROR',
      'apiKey is required: pass a LI.FI API key to createComposeSdk().',
    );
  }
  const fetchFn = options.fetch ?? globalThis.fetch;
  const base = options.baseUrl.replace(/\/$/, '');

  const baseHeaders: Record<string, string> = {
    Accept: 'application/json',
    [COMPOSER_SDK_VERSION_HEADER]: SDK_VERSION,
    'x-lifi-api-key': trimmedApiKey,
  };
  const transport: ComposeTransport = { fetchFn, baseHeaders };

  const getManifest = async (): Promise<ComposeManifest> =>
    getJson<ComposeManifest>(transport, `${base}/compose/manifest`);

  const getChains = async (): Promise<readonly ChainSummary[]> =>
    getJson<readonly ChainSummary[]>(transport, `${base}/chains`);

  const compile = async (
    request: ComposeCompileRequest,
  ): Promise<ComposeCompileResult> =>
    postCompile(transport, `${base}/compose`, request);

  const route = async (
    request: ComposeRouteRequest,
  ): Promise<ComposeCompileResult> =>
    postCompile(transport, `${base}/compose/route`, request);

  const getZapPacks = async (
    options?: GetZapPacksOptions,
  ): Promise<readonly ZapPackOverview[]> => {
    const params = new URLSearchParams();
    if (options?.protocols !== undefined) {
      // Backend expects a single comma-separated value, not repeated keys.
      const raw = options.protocols;
      const list = typeof raw === 'string' ? raw : raw.join(',');
      params.set('protocols', list);
    }
    const qs = params.toString();
    return getJson<readonly ZapPackOverview[]>(
      transport,
      `${base}/compose/zap-packs${qs ? `?${qs}` : ''}`,
    );
  };

  const simulate = async (
    request: SimulateRequest,
  ): Promise<SimulateResult> => {
    const url = `${base}/simulate`;
    const res = await send(transport, url, { method: 'POST', body: request });
    // Only 200 (carries `ok`/`revert`) and 422 (carries the `error` member of
    // the union) have a discriminated body. 422 is deliberately intercepted
    // here (not thrown as VALIDATION_ERROR) so callers get one exhaustive
    // `switch (result.status)`. Every other status is a transport error and is
    // thrown — including HTTP 400 (malformed input, no `status` body) and any
    // unexpected 2xx.
    if (res.status === 200 || res.status === 422) {
      return await parseSimulateBody(res, url);
    }
    const body = await res.text();
    throw errorFromHttpResponse(res.status, body, url);
  };

  return { getManifest, getChains, compile, route, getZapPacks, simulate };
};
