import {
  BEIGNET_ERROR_OWNER_HEADER,
  type ContractLike,
  getContractHeaderSchemas,
  type HttpContractConfig,
  methodSupportsRequestBody,
  parsePathTemplate,
  type ResolveContract,
  resolveContract,
  type StandardErrorResponseBody,
  type StandardSchema,
  type StandardSchemaV1,
} from "../contracts/index.js";
import { isErrorResponseBody, SchemaValidationError } from "../errors/index.js";
import { encodeQueryValue } from "../query-codec.js";
import type {
  CallArgs,
  ClientConfig,
  EndpointCallArgs,
  EndpointResult,
  InferEndpointErrorResponse,
  InferEndpointErrorResponseByStatus,
  InferEndpointErrorStatus,
  InferSuccessResponse,
} from "./types.js";

/**
 * Source category for a `ContractError`.
 */
export type ContractErrorSource = "http" | "client" | "network" | "contract";

/**
 * Narrow a contract error union by source.
 */
export type ContractErrorWithSource<
  TError,
  TSource extends ContractErrorSource,
> = Extract<TError, { readonly source: TSource }>;

/**
 * Narrow a contract error union by HTTP status.
 */
export type ContractErrorWithStatus<TError, TStatus extends number> = Extract<
  TError,
  { readonly status: TStatus }
>;

/**
 * Narrow a contract error union by Beignet error code.
 */
export type ContractErrorWithCode<TError, TCode extends string> = Extract<
  TError,
  { readonly code: TCode }
>;

/**
 * Error for a non-2xx HTTP response.
 */
export type HttpContractError<
  TBody = unknown,
  TStatus extends number = number,
> = ContractError<TBody, TStatus, "http"> & {
  readonly source: "http";
  readonly status: TStatus;
  readonly response: Response;
};

/**
 * Error created by the client before a network request is made.
 */
export type ClientContractError = ContractError<
  undefined,
  undefined,
  "client"
> & {
  readonly source: "client";
  readonly status: undefined;
  readonly response: undefined;
};

/**
 * Error created when the network request itself fails.
 */
export type NetworkContractError = ContractError<
  undefined,
  undefined,
  "network"
> & {
  readonly source: "network";
  readonly status: undefined;
  readonly response: undefined;
};

/**
 * Error created when a response violates the contract.
 */
export type ResponseContractError<
  TBody = unknown,
  TStatus extends number | undefined = number | undefined,
> = ContractError<TBody, TStatus, "contract"> & {
  readonly source: "contract";
  readonly status: TStatus;
};

/**
 * Union of all Beignet client error variants.
 */
export type AnyContractError =
  | HttpContractError
  | ClientContractError
  | NetworkContractError
  | ResponseContractError;

type EndpointCatalogErrorDefinition<TContract extends HttpContractConfig> =
  TContract["metadata"] extends { errors: infer TErrors }
    ? TErrors extends Record<
        string,
        { code: string; status: number; message: string }
      >
      ? TErrors[keyof TErrors]
      : never
    : never;

type InferErrorDefinitionDetails<TDef> = TDef extends {
  details: StandardSchemaV1;
}
  ? StandardSchemaV1.InferOutput<TDef["details"]>
  : unknown;

type StandardErrorBodyForDefinition<TDef extends { code: string }> =
  StandardErrorResponseBody & {
    code: TDef["code"];
    details?: InferErrorDefinitionDetails<TDef>;
  };

type EndpointCatalogContractError<TContract extends HttpContractConfig> =
  EndpointCatalogErrorDefinition<TContract> extends infer TDef
    ? TDef extends { code: string; status: number }
      ? HttpContractError<
          StandardErrorBodyForDefinition<TDef>,
          TDef["status"]
        > & {
          readonly code: TDef["code"];
          readonly details?: InferErrorDefinitionDetails<TDef>;
        }
      : never
    : never;

/**
 * Infer route-owned error catalog codes declared by a contract.
 */
export type InferEndpointErrorCode<TContract extends HttpContractConfig> =
  EndpointCatalogErrorDefinition<TContract>["code"];

/**
 * Infer the full typed error union for a contract endpoint.
 */
export type InferEndpointContractError<TContract extends HttpContractConfig> =
  | EndpointCatalogContractError<TContract>
  | {
      [TStatus in InferEndpointErrorStatus<TContract>]: HttpContractError<
        InferEndpointErrorResponseByStatus<TContract, TStatus>,
        TStatus
      >;
    }[InferEndpointErrorStatus<TContract>]
  | HttpContractError<InferEndpointErrorResponse<TContract>, number>
  | ClientContractError
  | NetworkContractError
  | ResponseContractError<
      InferEndpointErrorResponse<TContract>,
      number | undefined
    >;

/**
 * Error thrown by Beignet contract clients.
 *
 * `source` distinguishes HTTP error responses, client-side request mistakes,
 * network failures, and contract drift such as response validation failures.
 */
export class ContractError<
  TBody = unknown,
  TStatus extends number | undefined = number | undefined,
  TSource extends ContractErrorSource = ContractErrorSource,
> extends Error {
  /**
   * Error source category.
   */
  readonly source: TSource;
  /**
   * HTTP status when a response was available.
   */
  readonly status: TStatus;
  /**
   * Stable error code.
   */
  readonly code?: string;
  /**
   * Parsed response body when available.
   */
  readonly body?: TBody;
  /**
   * Structured error details when available.
   */
  readonly details?: unknown;
  /**
   * Native fetch response when available.
   */
  readonly response?: Response;
  override cause?: unknown;

  constructor(args: {
    source: TSource;
    status?: TStatus;
    code?: string;
    message: string;
    body?: TBody;
    details?: unknown;
    response?: Response;
    cause?: unknown;
  }) {
    super(args.message);
    this.name = "ContractError";
    this.source = args.source;
    this.status = args.status as TStatus;
    this.code = args.code;
    this.body = args.body;
    this.details = args.details;
    this.response = args.response;
    this.cause = args.cause;
  }

  /**
   * Check whether this error has a specific HTTP status code.
   */
  hasStatus<S extends number>(
    status: S,
  ): this is this & { readonly status: S } {
    return (this.status as number | undefined) === status;
  }

  /**
   * Check whether this error came from a specific source.
   */
  hasSource<S extends ContractErrorSource>(
    source: S,
  ): this is this & { readonly source: S } {
    return (this.source as ContractErrorSource) === source;
  }

  /**
   * Check whether this error has a specific error code.
   */
  hasCode<C extends string>(code: C): this is this & { code: C } {
    return this.code === code;
  }
}

/**
 * Type guard to check if an unknown error is a ContractError,
 * optionally narrowing by HTTP status code.
 *
 * @example
 * ```ts
 * try { await endpoint.call(...) }
 * catch (err) {
 *   if (isContractError(err, 404)) {
 *     // err.status is 404
 *   }
 *   if (isContractError(err)) {
 *     // err is ContractError
 *   }
 * }
 * ```
 */
export function isContractError(err: unknown): err is AnyContractError;
export function isContractError<S extends number>(
  err: unknown,
  status: S,
): err is HttpContractError<unknown, S>;
export function isContractError<
  TError extends AnyContractError,
  S extends number,
>(
  err: TError,
  criteria: { status: S },
): err is ContractErrorWithStatus<TError, S>;
export function isContractError<
  TError extends AnyContractError,
  S extends ContractErrorSource,
>(
  err: TError,
  criteria: { source: S },
): err is ContractErrorWithSource<TError, S>;
export function isContractError<
  TError extends ContractError,
  Status extends number,
  Source extends ContractErrorSource,
>(
  err: TError,
  criteria: { status: Status; source: Source },
): err is ContractErrorWithSource<
  ContractErrorWithStatus<TError, Status>,
  Source
>;
export function isContractError<
  TError extends ContractError,
  Code extends string,
>(
  err: TError,
  criteria: { code: Code },
): err is ContractErrorWithCode<TError, Code>;
export function isContractError<
  TError extends ContractError,
  Status extends number,
  Code extends string,
>(
  err: TError,
  criteria: { status: Status; code: Code },
): err is ContractErrorWithCode<ContractErrorWithStatus<TError, Status>, Code>;
export function isContractError<
  TError extends ContractError,
  Source extends ContractErrorSource,
  Code extends string,
>(
  err: TError,
  criteria: { source: Source; code: Code },
): err is ContractErrorWithCode<ContractErrorWithSource<TError, Source>, Code>;
export function isContractError<
  TError extends ContractError,
  Status extends number,
  Source extends ContractErrorSource,
  Code extends string,
>(
  err: TError,
  criteria: { status: Status; source: Source; code: Code },
): err is ContractErrorWithCode<
  ContractErrorWithSource<ContractErrorWithStatus<TError, Status>, Source>,
  Code
>;
export function isContractError<S extends number>(
  err: unknown,
  criteria: { status: S },
): err is ContractError<unknown, S>;
export function isContractError<S extends ContractErrorSource>(
  err: unknown,
  criteria: { source: S },
): err is ContractError<unknown, number | undefined, S>;
export function isContractError<
  Status extends number,
  Source extends ContractErrorSource,
>(
  err: unknown,
  criteria: { status: Status; source: Source },
): err is ContractErrorWithSource<HttpContractError<unknown, Status>, Source>;
export function isContractError<Code extends string>(
  err: unknown,
  criteria: { code: Code },
): err is ContractError<unknown, number | undefined> & { readonly code: Code };
export function isContractError(
  err: unknown,
  criteria?:
    | number
    | { code?: string; source?: ContractErrorSource; status?: number },
): err is AnyContractError {
  const status = typeof criteria === "number" ? criteria : criteria?.status;
  const source = typeof criteria === "object" ? criteria.source : undefined;
  const code = typeof criteria === "object" ? criteria.code : undefined;
  return (
    err instanceof ContractError &&
    (status === undefined || err.status === status) &&
    (source === undefined || err.source === source) &&
    (code === undefined || err.code === code)
  );
}

function formatQuotedList(values: string[]): string {
  return values.map((value) => `"${value}"`).join(", ");
}

class MalformedResponseJsonError extends Error {
  readonly parseError: unknown;

  constructor(parseError: unknown) {
    super("Response body contains malformed JSON.");
    this.name = "MalformedResponseJsonError";
    this.parseError = parseError;
  }
}

/**
 * Generate an idempotency key. Idempotency keys only need to be unique per
 * request, not cryptographically strong, so this degrades gracefully when
 * `crypto.randomUUID` is unavailable — notably in non-secure browsing
 * contexts (plain `http://` on a host other than `localhost`, e.g. a LAN IP
 * or a Tailscale hostname), where the Web Crypto API is not exposed and
 * `crypto.randomUUID()` would throw, breaking every mutation.
 */
function createIdempotencyKey(): string {
  const cryptoObj =
    typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
  if (typeof cryptoObj?.randomUUID === "function") {
    return cryptoObj.randomUUID();
  }

  const bytes = new Uint8Array(16);
  if (typeof cryptoObj?.getRandomValues === "function") {
    cryptoObj.getRandomValues(bytes);
  } else {
    for (let index = 0; index < bytes.length; index += 1) {
      bytes[index] = Math.floor(Math.random() * 256);
    }
  }
  // RFC 4122 version 4 layout.
  bytes[6] = (bytes[6] & 0x0f) | 0x40;
  bytes[8] = (bytes[8] & 0x3f) | 0x80;
  const hex = Array.from(bytes, (byte) =>
    byte.toString(16).padStart(2, "0"),
  ).join("");
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

function createMissingPathParamsMessage(
  path: string,
  missing: string[],
  provided: string[],
): string {
  const label = missing.length === 1 ? "parameter" : "parameters";
  const providedSuffix = provided.length
    ? ` (provided: ${provided.join(", ")})`
    : "";

  return `Missing required path ${label} ${formatQuotedList(missing)} for path "${path}"${providedSuffix}`;
}

/**
 * Validate data using a Standard Schema validator
 * Throws SchemaValidationError if validation fails
 */
async function validateSchema<T>(
  schema: StandardSchemaV1<unknown, T>,
  data: unknown,
): Promise<T> {
  const result = await schema["~standard"].validate(data);
  if (result.issues?.length) {
    throw new SchemaValidationError(result.issues);
  }
  if ("value" in result) {
    return result.value;
  }
  throw new Error("Invalid Standard Schema result: missing value");
}

function normalizeHeaderRecord(
  headers: Record<string, string | undefined>,
): Record<string, string> {
  const normalized: Record<string, string> = {};
  for (const [key, value] of Object.entries(headers)) {
    if (value !== undefined) {
      normalized[key.toLowerCase()] = value;
    }
  }
  return normalized;
}

function serializeParsedHeaders(parsed: unknown): Record<string, string> {
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    return {};
  }

  const headers: Record<string, string> = {};
  for (const [key, value] of Object.entries(parsed)) {
    if (value !== undefined && value !== null) {
      headers[key.toLowerCase()] = String(value);
    }
  }
  return headers;
}

async function validateHeaderSchemas(
  schemas: readonly StandardSchema[],
  headers: Record<string, string>,
): Promise<Record<string, string>> {
  let validatedHeaders = headers;

  for (const schema of schemas) {
    const parsed = await validateSchema(schema, headers);
    validatedHeaders = {
      ...validatedHeaders,
      ...serializeParsedHeaders(parsed),
    };
  }

  return validatedHeaders;
}

type PrimitiveParam = string | number | boolean;
type QueryParamValue =
  | PrimitiveParam
  | Date
  | Record<string, unknown>
  | null
  | undefined
  | Array<PrimitiveParam | Date | Record<string, unknown>>;

type PathParams = Record<string, PrimitiveParam>;
type QueryParams = Record<string, QueryParamValue>;

/**
 * Typed client endpoint for one contract.
 */
export class Endpoint<
  TContract extends HttpContractConfig,
  TProvidedHeaders extends string = never,
> {
  constructor(
    private contract: TContract,
    private config: ClientConfig<TProvidedHeaders>,
  ) {}

  /**
   * Check whether an unknown error is a `ContractError` for this endpoint.
   */
  isError(err: unknown): err is InferEndpointContractError<TContract>;
  isError<S extends InferEndpointErrorStatus<TContract>>(
    err: unknown,
    status: S,
  ): err is ContractErrorWithStatus<InferEndpointContractError<TContract>, S>;
  isError<S extends number>(
    err: unknown,
    status: S,
  ): err is HttpContractError<unknown, S>;
  isError<S extends InferEndpointErrorStatus<TContract>>(
    err: unknown,
    criteria: { status: S },
  ): err is ContractErrorWithStatus<InferEndpointContractError<TContract>, S>;
  isError<S extends ContractErrorSource>(
    err: unknown,
    criteria: { source: S },
  ): err is ContractErrorWithSource<InferEndpointContractError<TContract>, S>;
  isError<C extends InferEndpointErrorCode<TContract>>(
    err: unknown,
    criteria: { code: C },
  ): err is ContractErrorWithCode<InferEndpointContractError<TContract>, C>;
  isError<C extends string>(
    err: unknown,
    criteria: { code: C },
  ): err is InferEndpointContractError<TContract> & { readonly code: C };
  isError<
    Status extends InferEndpointErrorStatus<TContract>,
    Source extends ContractErrorSource,
  >(
    err: unknown,
    criteria: { status: Status; source: Source },
  ): err is ContractErrorWithSource<
    ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
    Source
  >;
  isError<
    Status extends InferEndpointErrorStatus<TContract>,
    C extends InferEndpointErrorCode<TContract>,
  >(
    err: unknown,
    criteria: { status: Status; code: C },
  ): err is ContractErrorWithCode<
    ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
    C
  >;
  isError<
    Source extends ContractErrorSource,
    C extends InferEndpointErrorCode<TContract>,
  >(
    err: unknown,
    criteria: { source: Source; code: C },
  ): err is ContractErrorWithCode<
    ContractErrorWithSource<InferEndpointContractError<TContract>, Source>,
    C
  >;
  isError<
    Status extends InferEndpointErrorStatus<TContract>,
    Source extends ContractErrorSource,
    C extends InferEndpointErrorCode<TContract>,
  >(
    err: unknown,
    criteria: { status: Status; source: Source; code: C },
  ): err is ContractErrorWithCode<
    ContractErrorWithSource<
      ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
      Source
    >,
    C
  >;
  isError(
    err: unknown,
    criteria?:
      | number
      | { code?: string; source?: ContractErrorSource; status?: number },
  ): err is InferEndpointContractError<TContract> {
    return isContractError(err, criteria as never);
  }

  /**
   * Call the endpoint and return the parsed success body.
   *
   * Throws `ContractError` when the request fails or the response violates the
   * contract.
   */
  async call(
    ...callArgs: CallArgs<TContract, TProvidedHeaders>
  ): Promise<InferSuccessResponse<TContract>> {
    const result = await this.safeCall(...callArgs);
    if (!result.ok) {
      throw result.error;
    }
    return result.data;
  }

  /**
   * Call the endpoint and return a typed result instead of throwing
   * `ContractError`.
   */
  async safeCall(
    ...callArgs: CallArgs<TContract, TProvidedHeaders>
  ): Promise<EndpointResult<TContract, InferEndpointContractError<TContract>>> {
    const args = (callArgs[0] ?? {}) as EndpointCallArgs<
      TContract,
      TProvidedHeaders
    >;
    let phase: "client" | "network" | "contract" = "client";
    let response: Response | undefined;
    let responseStatus: number | undefined;

    try {
      if (args.body !== undefined && args.rawBody !== undefined) {
        throw this.createError({
          code: "INVALID_REQUEST_BODY",
          message: "Pass either body or rawBody, not both.",
        });
      }

      const methodSupportsBody = methodSupportsRequestBody(
        this.contract.method,
      );
      if (
        (args.body !== undefined || args.rawBody !== undefined) &&
        !methodSupportsBody
      ) {
        throw this.createError({
          code: "INVALID_REQUEST_BODY",
          message: `Request bodies are not supported for ${this.contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
        });
      }

      let requestBody: BodyInit | undefined;
      let requestBodyType: "json" | "raw" | undefined;
      if (args.rawBody !== undefined && methodSupportsBody) {
        requestBody = args.rawBody;
        requestBodyType = "raw";
      }

      if (args.body !== undefined && methodSupportsBody) {
        let bodyToSend = args.body;
        if (this.config.validateInput && this.contract.body) {
          try {
            bodyToSend = (await validateSchema(
              this.contract.body,
              args.body,
            )) as typeof bodyToSend;
          } catch (err) {
            if (err instanceof SchemaValidationError) {
              throw this.createError({
                code: "INPUT_VALIDATION_ERROR",
                message: "Body validation failed",
                details: err.issues,
              });
            }
            throw err;
          }
        }
        requestBody = JSON.stringify(bodyToSend);
        requestBodyType = "json";
      }

      const url = await this.buildUrl(
        args.path as PathParams | undefined,
        args.query as QueryParams | undefined,
      );
      let headers = await this.buildHeaders(
        args.headers as Record<string, string> | undefined,
        requestBodyType === "json",
      );
      const idempotencyMeta = this.contract.metadata?.idempotency;
      if (idempotencyMeta) {
        const idempotencyHeader = (
          idempotencyMeta.header ?? "idempotency-key"
        ).toLowerCase();
        if (!headers[idempotencyHeader]) {
          headers[idempotencyHeader] =
            args.idempotencyKey ?? createIdempotencyKey();
        }
      }
      if (this.config.validateInput) {
        const headerSchemas = getContractHeaderSchemas(this.contract.headers);
        if (headerSchemas.length > 0) {
          try {
            headers = await validateHeaderSchemas(headerSchemas, headers);
          } catch (err) {
            if (err instanceof SchemaValidationError) {
              throw this.createError({
                code: "INPUT_VALIDATION_ERROR",
                message: "Headers validation failed",
                details: err.issues,
              });
            }
            throw err;
          }
        }
      }
      const fetchFn = this.config.fetch || fetch;

      const options: RequestInit = {
        method: this.contract.method,
        headers,
        signal: args.signal,
      };

      if (requestBody !== undefined) {
        options.body = requestBody;
      }

      phase = "network";
      response = await fetchFn(url, options);
      phase = "contract";
      responseStatus = response.status;
      const shouldValidateResponses = this.config.validateResponses !== false;

      // Handle non-2xx responses
      if (!response.ok) {
        let errorBody: unknown;
        try {
          errorBody = await parseResponseBody(response);
        } catch (parseErr) {
          if (!(parseErr instanceof MalformedResponseJsonError)) {
            throw parseErr;
          }
          // JSON parse failed — still report the HTTP error
          throw this.createError({
            status: response.status,
            code: "INVALID_JSON",
            message: createInvalidJsonMessage("error", response.status),
            cause: parseErr.parseError,
            response,
            source: "contract",
          });
        }

        const validatedError = shouldValidateResponses
          ? await validateErrorBodyOrStandardEnvelope(
              this.contract,
              response,
              errorBody,
            )
          : errorBody;

        const errorPayload = getErrorPayload(validatedError);
        throw this.createError({
          status: response.status,
          code: errorPayload.code || "HTTP_ERROR",
          message: errorPayload.message || response.statusText,
          details: errorPayload.details,
          body: validatedError,
          response,
          source: "http",
        });
      }

      // Parse response
      let data: unknown;
      try {
        data = await parseResponseBody(response);
      } catch (parseErr) {
        if (!(parseErr instanceof MalformedResponseJsonError)) {
          throw parseErr;
        }
        throw this.createError({
          status: response.status,
          code: "INVALID_JSON",
          message: createInvalidJsonMessage("success", response.status),
          cause: parseErr.parseError,
          response,
          source: "contract",
        });
      }

      // Validate response if schema exists for this status
      if (shouldValidateResponses) {
        const statusKey = String(response.status);
        const hasSchema = statusKey in this.contract.responses;
        const hasDeclaredResponses =
          Object.keys(this.contract.responses).length > 0;
        const responseSchema = this.contract.responses[response.status];
        if (hasSchema && responseSchema === null) {
          if (data !== undefined && data !== null) {
            throw this.createError({
              status: response.status,
              code: "RESPONSE_VALIDATION_ERROR",
              message: `Response validation failed for ${this.contract.method} ${this.contract.path} (status ${response.status}, contract: ${this.contract.name})`,
              details: [
                {
                  message:
                    "Response body must be empty for a null response schema.",
                },
              ],
              body: data,
              response,
              source: "contract",
            });
          }
        } else if (hasSchema && responseSchema) {
          try {
            data = await validateSchema(responseSchema, data);
          } catch (err) {
            if (err instanceof SchemaValidationError) {
              throw this.createError({
                status: response.status,
                code: "RESPONSE_VALIDATION_ERROR",
                message: `Response validation failed for ${this.contract.method} ${this.contract.path} (status ${response.status}, contract: ${this.contract.name})`,
                details: err.issues,
                cause: err,
                body: data,
                response,
                source: "contract",
              });
            }
            throw err;
          }
        } else if (!hasSchema && hasDeclaredResponses) {
          throw this.createError({
            status: response.status,
            code: "UNDECLARED_RESPONSE_STATUS",
            message: `Server returned undeclared status ${response.status} for ${this.contract.method} ${this.contract.path} (contract: ${this.contract.name})`,
            body: data,
            response,
            source: "contract",
          });
        }
      }

      return {
        ok: true,
        status: response.status,
        data: data as InferSuccessResponse<TContract>,
        response,
      };
    } catch (err: unknown) {
      if (err instanceof ContractError) {
        const error = err as InferEndpointContractError<TContract>;
        return {
          ok: false,
          status: error.status,
          error,
          response: error.response,
        };
      }
      const source = phase;
      const error = this.createError({
        code:
          source === "network"
            ? "NETWORK_ERROR"
            : source === "contract"
              ? "RESPONSE_PROCESSING_ERROR"
              : "CLIENT_ERROR",
        message:
          err instanceof Error
            ? err.message
            : source === "network"
              ? "Network request failed"
              : source === "contract"
                ? "Response processing failed"
                : "Client request preparation failed",
        cause: err,
        source,
        ...(source === "contract" ? { status: responseStatus, response } : {}),
      }) as InferEndpointContractError<TContract>;
      return {
        ok: false,
        status: error.status,
        error,
        response: error.response,
      };
    }
  }

  /**
   * Build the full URL with path and query parameters
   */
  private async buildUrl(
    path?: PathParams,
    query?: QueryParams,
  ): Promise<string> {
    let parsedPath: ReturnType<typeof parsePathTemplate>;
    try {
      parsedPath = parsePathTemplate(this.contract.path);
    } catch (cause) {
      throw new ContractError({
        source: "client",
        code: "INVALID_PATH_TEMPLATE",
        message: cause instanceof Error ? cause.message : String(cause),
        cause,
      });
    }
    let pathToSerialize = path;

    // Replace path parameters
    if (path && parsedPath.keys.length > 0) {
      // Validate path params if schema exists and validation is enabled
      if (this.config.validateInput && this.contract.pathParams) {
        try {
          pathToSerialize = (await validateSchema(
            this.contract.pathParams,
            path,
          )) as PathParams;
        } catch (err) {
          if (err instanceof SchemaValidationError) {
            throw this.createError({
              code: "INPUT_VALIDATION_ERROR",
              message: "Path params validation failed",
              details: err.issues,
            });
          }
          throw err;
        }
      }
    }

    const normalizedPath = pathToSerialize ?? path;
    const missingPathParams = [
      ...new Set(
        parsedPath.keys.filter((key) => normalizedPath?.[key] === undefined),
      ),
    ];
    if (missingPathParams.length) {
      const provided = path ? Object.keys(path) : [];
      throw new ContractError({
        source: "client",
        code: "MISSING_PATH_PARAMS",
        message: createMissingPathParamsMessage(
          this.contract.path,
          missingPathParams,
          provided,
        ),
      });
    }

    let url = `/${parsedPath.segments
      .map((segment) =>
        segment.kind === "static"
          ? segment.value
          : encodeURIComponent(String(normalizedPath?.[segment.name])),
      )
      .join("/")}`;

    // Add query parameters
    let queryToSerialize = query;
    if (query) {
      // Validate query params if schema exists and validation is enabled
      if (this.config.validateInput && this.contract.query) {
        try {
          queryToSerialize = (await validateSchema(
            this.contract.query,
            query,
          )) as QueryParams;
        } catch (err) {
          if (err instanceof SchemaValidationError) {
            throw this.createError({
              code: "INPUT_VALIDATION_ERROR",
              message: "Query params validation failed",
              details: err.issues,
            });
          }
          throw err;
        }
      }

      const params = new URLSearchParams();
      const normalizedQuery = queryToSerialize ?? query;
      for (const [key, value] of Object.entries(normalizedQuery)) {
        if (value !== undefined && value !== null) {
          params.append(key, this.serializeQueryParam(key, value));
        }
      }
      const queryString = params.toString();
      if (queryString) {
        url += `?${queryString}`;
      }
    }

    // Prepend base URL, normalizing trailing/leading slashes
    const baseUrl = this.config.baseUrl || "";
    if (baseUrl && url.startsWith("/") && baseUrl.endsWith("/")) {
      return baseUrl + url.slice(1);
    }
    return baseUrl + url;
  }

  private serializeQueryParam(key: string, value: unknown): string {
    try {
      return encodeQueryValue(value);
    } catch {
      throw this.invalidQueryParam(key, "contains a non-serializable value");
    }
  }

  private invalidQueryParam(key: string, reason: string): ContractError {
    return new ContractError({
      source: "client",
      code: "INVALID_QUERY_PARAM",
      message: `Query parameter "${key}" ${reason}. Use primitive values, Dates, JSON-serializable objects, or arrays of those values.`,
    });
  }

  /**
   * Build request headers
   */
  private async buildHeaders(
    customHeaders?: Record<string, string>,
    hasJsonBody = false,
  ): Promise<Record<string, string>> {
    const configHeaders =
      typeof this.config.headers === "function"
        ? await this.config.headers()
        : this.config.headers || {};

    const headers = normalizeHeaderRecord({
      ...configHeaders,
      ...customHeaders,
    });

    // Only set Content-Type for methods that can have a body
    if (hasJsonBody && methodSupportsRequestBody(this.contract.method)) {
      const hasContentType = Object.keys(headers).some(
        (k) => k.toLowerCase() === "content-type",
      );
      if (!hasContentType) {
        headers["content-type"] = "application/json";
      }
    }

    return headers;
  }

  /**
   * Create a contract error
   */
  private createError(options: {
    code: string;
    message: string;
    status?: number;
    details?: unknown;
    cause?: unknown;
    body?: unknown;
    response?: Response;
    source?: ContractErrorSource;
  }): AnyContractError {
    const source = options.source ?? "client";
    const isLocalError = source === "client" || source === "network";
    return new ContractError({
      source,
      status: isLocalError ? undefined : options.status,
      code: options.code,
      message: options.message,
      body: isLocalError ? undefined : options.body,
      details: options.details,
      response: isLocalError ? undefined : options.response,
      cause: options.cause,
    }) as AnyContractError;
  }
}

/**
 * Client for making contract-based requests.
 */
export class Client<TProvidedHeaders extends string = never> {
  constructor(private config: ClientConfig<TProvidedHeaders>) {}

  /**
   * Create an endpoint wrapper for a contract.
   *
   * Accepts either a plain `HttpContractConfig` or a builder with a `.config`
   * property.
   */
  endpoint<TContractLike extends ContractLike>(
    contract: TContractLike,
  ): Endpoint<ResolveContract<TContractLike>, TProvidedHeaders> {
    const resolved = resolveContract(contract);
    return new Endpoint(resolved, this.config);
  }
}

/**
 * Create a configured Beignet client.
 */
export function createClient<const TProvidedHeaders extends string = never>(
  config: ClientConfig<TProvidedHeaders> = {},
): Client<TProvidedHeaders> {
  return new Client(config);
}

async function parseResponseBody(response: Response): Promise<unknown> {
  if (response.status === 204 || response.status === 205) {
    return undefined;
  }

  const text = await response.text();
  if (text === "") {
    return undefined;
  }

  const contentType = response.headers.get("content-type");
  if (contentType?.includes("application/json")) {
    try {
      return JSON.parse(text);
    } catch (error) {
      throw new MalformedResponseJsonError(error);
    }
  }

  return text;
}

function createInvalidJsonMessage(
  context: "success" | "error",
  status: number,
): string {
  return `Failed to parse JSON ${context} response (status ${status})`;
}

function createErrorResponseValidationMessage(
  contract: HttpContractConfig,
  status: number,
): string {
  return `Error response validation failed for ${contract.method} ${contract.path} (status ${status}, contract: ${contract.name})`;
}

function hasFrameworkErrorOwner(response: Response): boolean {
  return response.headers.get(BEIGNET_ERROR_OWNER_HEADER) === "framework";
}

async function validateErrorBodyOrStandardEnvelope(
  contract: HttpContractConfig,
  response: Response,
  errorBody: unknown,
): Promise<unknown> {
  const errorSchema = contract.responses[response.status];
  const hasDeclaredResponses = Object.keys(contract.responses).length > 0;

  if (errorSchema === null) {
    if (errorBody === undefined || errorBody === null) {
      return undefined;
    }
    if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
      return errorBody;
    }

    throw new ContractError({
      source: "contract",
      status: response.status,
      code: "ERROR_RESPONSE_VALIDATION_ERROR",
      message: createErrorResponseValidationMessage(contract, response.status),
      details: [
        {
          message: "Response body must be empty for a null response schema.",
        },
      ],
      body: errorBody,
      response,
    });
  }

  if (!errorSchema) {
    if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
      return errorBody;
    }
    if (hasDeclaredResponses) {
      throw new ContractError({
        source: "contract",
        status: response.status,
        code: "UNDECLARED_ERROR_STATUS",
        message: `Server returned undeclared error status ${response.status} for ${contract.method} ${contract.path} (contract: ${contract.name})`,
        body: errorBody,
        response,
      });
    }
    return errorBody;
  }

  try {
    return await validateSchema(errorSchema, errorBody);
  } catch (err) {
    if (!(err instanceof SchemaValidationError)) {
      throw err;
    }

    if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
      return errorBody;
    }

    throw new ContractError({
      source: "contract",
      status: response.status,
      code: "ERROR_RESPONSE_VALIDATION_ERROR",
      message: createErrorResponseValidationMessage(contract, response.status),
      details: err.issues,
      cause: err,
      body: errorBody,
      response,
    });
  }
}

function getErrorPayload(body: unknown): {
  code?: string;
  message?: string;
  details?: unknown;
} {
  if (typeof body !== "object" || body === null) {
    return {};
  }

  const payload = body as Record<string, unknown>;
  return {
    code: typeof payload.code === "string" ? payload.code : undefined,
    message: typeof payload.message === "string" ? payload.message : undefined,
    details: payload.details,
  };
}
