/**
 * @beignet/core/openapi
 *
 * OpenAPI 3.1 generation from Beignet contracts
 */

import {
  type AnyContract,
  type ContractDeprecationMeta,
  type ContractLike,
  getContractHeaderSchemas,
  methodSupportsRequestBody,
  parsePathTemplate,
  resolveContract,
  STANDARD_ERROR_RESPONSE_SCHEMA,
} from "../contracts/index.js";
import {
  assertValidContractLifecycle,
  getContractOperationId,
} from "../contracts/lifecycle.js";
import {
  comparePathParamsToTemplate,
  formatPathParamsMismatch,
} from "../contracts/schema-shape.js";
import {
  createZodIntrospector,
  createZodSchemaConverter,
  type SchemaConverter,
  type SchemaIntrospector,
  type SchemaIO,
} from "./schema-introspector.js";

// Re-export the introspector types for consumers who want custom implementations
export {
  createZodIntrospector,
  createZodSchemaConverter,
  type SchemaConverter,
  type SchemaIntrospector,
} from "./schema-introspector.js";

/**
 * OpenAPI 3.1 info object.
 */
export interface OpenAPIInfo {
  /**
   * API title.
   */
  title: string;
  /**
   * API version.
   */
  version: string;
  /**
   * API description.
   */
  description?: string;
  /**
   * Terms of service URL.
   */
  termsOfService?: string;
  /**
   * API contact information.
   */
  contact?: {
    name?: string;
    url?: string;
    email?: string;
  };
  /**
   * API license information.
   */
  license?: {
    name: string;
    url?: string;
  };
}

/**
 * OpenAPI 3.1 server object.
 */
export interface OpenAPIServer {
  /**
   * Server URL.
   */
  url: string;
  /**
   * Server description.
   */
  description?: string;
  /**
   * Server URL variables.
   */
  variables?: Record<
    string,
    {
      default: string;
      enum?: string[];
      description?: string;
    }
  >;
}

/**
 * OpenAPI 3.1 security scheme object.
 */
export type OpenAPISecurityScheme =
  | {
      type: "apiKey";
      name: string;
      in: "query" | "header" | "cookie";
      description?: string;
    }
  | {
      type: "http";
      scheme: string;
      bearerFormat?: string;
      description?: string;
    }
  | {
      type: "oauth2";
      flows: Record<string, unknown>;
      description?: string;
    }
  | {
      type: "openIdConnect";
      openIdConnectUrl: string;
      description?: string;
    };

/**
 * OpenAPI schema object.
 */
export type SchemaObject = Record<string, unknown>;

/**
 * OpenAPI reference object.
 */
export type ReferenceObject = { $ref: string };

/**
 * OpenAPI parameter object.
 */
export interface ParameterObject {
  /**
   * Parameter name.
   */
  name: string;
  /**
   * Parameter location.
   */
  in: "path" | "query" | "header" | "cookie";
  /**
   * Whether the parameter is required.
   */
  required?: boolean;
  /**
   * Parameter schema.
   */
  schema?: SchemaObject | ReferenceObject;
  /**
   * Parameter description.
   */
  description?: string;
  /**
   * Whether the parameter is deprecated.
   */
  deprecated?: boolean;
}

/**
 * OpenAPI request body object.
 */
export interface RequestBodyObject {
  /**
   * Whether the request body is required.
   */
  required?: boolean;
  /**
   * Request body description.
   */
  description?: string;
  /**
   * Request body content keyed by media type.
   */
  content: Record<
    string,
    {
      schema?: SchemaObject | ReferenceObject;
    }
  >;
}

/**
 * OpenAPI response object.
 */
export interface ResponseObject {
  /**
   * Response description.
   */
  description: string;
  /**
   * Response content keyed by media type.
   */
  content?: Record<
    string,
    {
      schema?: SchemaObject | ReferenceObject;
      examples?: Record<
        string,
        {
          summary?: string;
          value?: unknown;
        }
      >;
    }
  >;
}

/**
 * OpenAPI operation object.
 */
export interface OperationObject {
  /**
   * Operation ID.
   */
  operationId?: string;
  /**
   * Operation summary.
   */
  summary?: string;
  /**
   * Operation description.
   */
  description?: string;
  /**
   * Operation tags.
   */
  tags?: string[];
  /**
   * Whether the operation is deprecated.
   */
  deprecated?: boolean;
  /** Beignet lifecycle details for a deprecated operation. */
  "x-beignet-deprecation"?: ContractDeprecationMeta;
  /**
   * External documentation.
   */
  externalDocs?: {
    description?: string;
    url: string;
  };
  /**
   * Operation-specific security requirements.
   */
  security?: Array<Record<string, string[]>>;
  /**
   * Operation parameters.
   */
  parameters?: ParameterObject[];
  /**
   * Operation request body.
   */
  requestBody?: RequestBodyObject;
  /**
   * Operation responses keyed by status code.
   */
  responses: Record<string, ResponseObject>;
}

/**
 * OpenAPI path item object.
 */
export interface PathItemObject {
  get?: OperationObject;
  post?: OperationObject;
  put?: OperationObject;
  patch?: OperationObject;
  delete?: OperationObject;
  head?: OperationObject;
  options?: OperationObject;
}

/**
 * OpenAPI paths object.
 */
export type PathsObject = Record<string, PathItemObject>;

/**
 * OpenAPI components object.
 */
export interface ComponentsObject {
  /**
   * Reusable schemas keyed by component name.
   */
  schemas?: Record<string, SchemaObject>;
  /**
   * Reusable security schemes keyed by scheme name.
   */
  securitySchemes?: Record<string, OpenAPISecurityScheme>;
}

/**
 * OpenAPI 3.1 document.
 */
export interface OpenAPIObject {
  /**
   * OpenAPI version.
   */
  openapi: "3.1.0";
  /**
   * API info.
   */
  info: OpenAPIInfo;
  /**
   * Server list.
   */
  servers?: OpenAPIServer[];
  /**
   * API paths.
   */
  paths: PathsObject;
  /**
   * Reusable components.
   */
  components?: ComponentsObject;
  /**
   * Global security requirements.
   */
  security?: Array<Record<string, string[]>>;
}

/**
 * Options for generating an OpenAPI document.
 */
export interface OpenAPIGeneratorOptions {
  /** API title */
  title: string;
  /** API version */
  version: string;
  /** API description */
  description?: string;
  /** Server configurations */
  servers?: OpenAPIServer[];
  /** Media type for JSON content (default: application/json) */
  jsonMediaType?: string;
  /** Security schemes for authentication */
  securitySchemes?: Record<string, OpenAPISecurityScheme>;
  /** Global security requirements */
  security?: Array<Record<string, string[]>>;
  /**
   * Schema introspector for reading metadata from schema objects.
   * Defaults to a Zod introspector. Provide a custom implementation
   * to support other schema libraries.
   */
  schemaIntrospector?: SchemaIntrospector;
  /**
   * Schema converters used to turn contract schemas into OpenAPI schemas.
   * Custom converters run before Beignet's default Zod converter.
   */
  schemaConverters?: readonly SchemaConverter[];
}

/**
 * Internal state for generator
 */
type GeneratorState = {
  components: ComponentsObject;
  jsonMediaType: string;
  introspector: SchemaIntrospector;
  schemaConverters: readonly SchemaConverter[];
};

/**
 * Contract input accepted by the OpenAPI generator.
 */
export type ContractInput = ContractLike;

/**
 * Convert contracts to an OpenAPI 3.1 document.
 *
 * @param contracts - Array of HTTP contracts (ContractBuilder instances or configs)
 * @param options - OpenAPI generation options
 * @returns OpenAPI 3.1 document object
 *
 * @example
 * ```ts
 * import { contractsToOpenAPI } from "@beignet/core/openapi";
 * import { getTodo, listTodos } from "./contracts";
 *
 * const spec = contractsToOpenAPI(
 *   [getTodo, listTodos],
 *   {
 *     title: "Todo API",
 *     version: "1.0.0",
 *     servers: [{ url: "https://api.example.com" }],
 *   }
 * );
 * ```
 */
export function contractsToOpenAPI(
  contracts: readonly ContractInput[],
  options: OpenAPIGeneratorOptions,
): OpenAPIObject {
  const paths: PathsObject = {};
  const components: ComponentsObject = {
    schemas: {},
    securitySchemes: options.securitySchemes ?? {},
  };

  const state: GeneratorState = {
    components,
    jsonMediaType: options.jsonMediaType ?? "application/json",
    introspector: options.schemaIntrospector ?? createZodIntrospector(),
    schemaConverters: [
      ...(options.schemaConverters ?? []),
      createZodSchemaConverter(),
    ],
  };
  const operationIds = new Map<string, string>();

  for (const contract of contracts) {
    const config = resolveContract(contract);
    assertValidContractLifecycle(config);
    const operationId = getContractOperationId(config);
    const route = `${config.method} ${config.path}`;
    const conflictingRoute = operationIds.get(operationId);
    if (conflictingRoute) {
      throw new Error(
        `Duplicate OpenAPI operationId: "${operationId}" is used by both ${conflictingRoute} and ${route}. Operation IDs must be unique within an OpenAPI document.`,
      );
    }
    operationIds.set(operationId, route);
    addContractToPaths(config, paths, state);
  }

  const openapi: OpenAPIObject = {
    openapi: "3.1.0",
    info: {
      title: options.title,
      version: options.version,
      description: options.description,
    },
    servers: options.servers,
    paths,
    components:
      Object.keys(components.schemas ?? {}).length > 0 ||
      Object.keys(components.securitySchemes ?? {}).length > 0
        ? components
        : undefined,
    security: options.security,
  };

  return openapi;
}

// =============================================================================
// OpenAPI Generation Helpers
// =============================================================================

/**
 * Add a single contract to the paths object
 */
function addContractToPaths(
  contract: AnyContract,
  paths: PathsObject,
  state: GeneratorState,
): void {
  const pathKey = parsePathTemplate(contract.path).openApiPath;
  if (!paths[pathKey]) {
    paths[pathKey] = {};
  }
  const pathItem = paths[pathKey];

  const meta = contract.metadata?.openapi;
  const deprecation = contract.metadata?.deprecation;

  const operation: OperationObject = {
    operationId: getContractOperationId(contract),
    summary: meta?.summary,
    description: meta?.description,
    tags: meta?.tags,
    deprecated: deprecation ? true : meta?.deprecated,
    "x-beignet-deprecation": deprecation,
    externalDocs: meta?.externalDocs,
    security: meta?.security,
    parameters: [],
    responses: {},
  };

  addPathParams(contract, operation, state);
  addQueryParams(contract, operation, state);
  addHeaderParams(contract, operation, state);
  addRequestBody(contract, operation, state);
  addResponses(contract, operation, state);
  applyOpenAPIOverrides(operation, meta);

  // Clean up empty parameters array
  if (operation.parameters?.length === 0) {
    delete operation.parameters;
  }

  const methodKey = contract.method.toLowerCase() as
    | "get"
    | "post"
    | "put"
    | "patch"
    | "delete"
    | "head"
    | "options";

  pathItem[methodKey] = operation;
}

function applyOpenAPIOverrides(
  operation: OperationObject,
  meta: AnyContract["metadata"]["openapi"] | undefined,
): void {
  if (!meta) return;

  for (const parameter of meta.parameters ?? []) {
    addParameter(operation, parameter);
  }

  if (meta.requestBody) {
    operation.requestBody = meta.requestBody;
  }

  for (const [status, response] of Object.entries(meta.responses ?? {})) {
    operation.responses[status] = response;
  }
}

function addParameter(
  operation: OperationObject,
  parameter: ParameterObject,
): void {
  if (!operation.parameters) {
    operation.parameters = [];
  }

  const existingIndex = operation.parameters.findIndex(
    (existing) =>
      existing.in === parameter.in && existing.name === parameter.name,
  );
  if (existingIndex >= 0) {
    operation.parameters[existingIndex] = parameter;
    return;
  }

  operation.parameters.push(parameter);
}

/**
 * Add path parameters from contract to operation.
 */
function addPathParams(
  contract: AnyContract,
  operation: OperationObject,
  state: GeneratorState,
): void {
  const pathKeys = parsePathTemplate(contract.path).keys;
  if (!contract.pathParams) {
    for (const key of pathKeys) {
      addParameter(operation, {
        name: key,
        in: "path",
        required: true,
        schema: { type: "string" },
      });
    }
    return;
  }

  const shape = state.introspector.getShape(contract.pathParams);
  if (!shape) {
    for (const key of pathKeys) {
      addParameter(operation, {
        name: key,
        in: "path",
        required: true,
        schema: { type: "string" },
      });
    }
    return;
  }

  const { missingKeys, extraKeys } = comparePathParamsToTemplate({
    pathKeys,
    shapeKeys: Object.keys(shape),
  });
  if (missingKeys.length > 0 || extraKeys.length > 0) {
    const details = formatPathParamsMismatch({ missingKeys, extraKeys });
    throw new Error(
      `Path parameters for contract "${contract.name}" must match "${contract.path}" (${details}).`,
    );
  }

  for (const key of pathKeys) {
    const field = shape[key];
    const paramSchemaRef = schemaToConvertedSchemaRef(
      field,
      `${contract.name}_path_${key}`,
      state,
      "input",
    );

    const description = state.introspector.getDescription(field);

    const param: ParameterObject = {
      name: key,
      in: "path",
      required: true,
      schema: paramSchemaRef,
      description,
    };

    addParameter(operation, param);
  }
}

/**
 * Add query parameters from contract to operation.
 */
function addQueryParams(
  contract: AnyContract,
  operation: OperationObject,
  state: GeneratorState,
): void {
  if (!contract.query) return;

  const shape = state.introspector.getShape(contract.query);
  if (!shape) return;

  for (const key of Object.keys(shape)) {
    const originalField = shape[key];
    const optional = state.introspector.isOptional(originalField);

    const field = originalField;
    const description = parameterDescription(field, optional, state);

    const paramSchemaRef = schemaToConvertedSchemaRef(
      field,
      `${contract.name}_query_${key}`,
      state,
      "input",
    );

    const param: ParameterObject = {
      name: key,
      in: "query",
      required: !optional,
      schema: paramSchemaRef,
      description,
    };

    addParameter(operation, param);
  }
}

/**
 * Add header parameters from contract to operation.
 */
function addHeaderParams(
  contract: AnyContract,
  operation: OperationObject,
  state: GeneratorState,
): void {
  const headerSchemas = getContractHeaderSchemas(contract.headers);
  for (const headerSchema of headerSchemas) {
    const shape = state.introspector.getShape(headerSchema);
    if (!shape) continue;

    for (const key of Object.keys(shape)) {
      const originalField = shape[key];
      const optional = state.introspector.isOptional(originalField);
      const description = parameterDescription(originalField, optional, state);

      const paramSchemaRef = schemaToConvertedSchemaRef(
        originalField,
        `${contract.name}_header_${key}`,
        state,
        "input",
      );

      addParameter(operation, {
        name: key,
        in: "header",
        required: !optional,
        schema: paramSchemaRef,
        description,
      });
    }
  }
}

function parameterDescription(
  field: unknown,
  optional: boolean,
  state: GeneratorState,
): string | undefined {
  return (
    state.introspector.getDescription(field) ??
    (optional
      ? state.introspector.getDescription(
          state.introspector.unwrapOptional(field),
        )
      : undefined)
  );
}

/**
 * Add request body from contract to operation
 */
function addRequestBody(
  contract: AnyContract,
  operation: OperationObject,
  state: GeneratorState,
): void {
  if (!contract.body) return;
  if (!methodSupportsRequestBody(contract.method)) {
    throw new Error(
      `Request bodies are not supported for ${contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
    );
  }

  const schemaRef = schemaToConvertedSchemaRef(
    contract.body,
    `${contract.name}_body`,
    state,
    "input",
  );

  const description = state.introspector.getDescription(contract.body);

  operation.requestBody = {
    required: true,
    description,
    content: {
      [state.jsonMediaType]: {
        schema: schemaRef,
      },
    },
  };
}

/**
 * Add responses from contract to operation
 */
function addResponses(
  contract: AnyContract,
  operation: OperationObject,
  state: GeneratorState,
): void {
  // Process all responses (both success and error status codes)
  for (const [statusKey, zodSchema] of Object.entries(contract.responses)) {
    const status = statusKey; // Keep as string
    const catalogErrors = getCatalogErrorsForStatus(contract, Number(status));

    // null schema means void/empty response (e.g. .responses({ 204: null }))
    const hasSchema = zodSchema != null;

    const schemaRef = hasSchema
      ? catalogErrors.length > 0 && zodSchema === STANDARD_ERROR_RESPONSE_SCHEMA
        ? catalogErrorResponseSchemaRef(
            catalogErrors,
            `${contract.name}_response_${status}`,
            state,
          )
        : schemaToSchemaRef(
            zodSchema,
            `${contract.name}_response_${status}`,
            state,
            "output",
          )
      : undefined;

    const described = hasSchema
      ? state.introspector.getDescription(zodSchema)
      : undefined;

    let description: string;

    if (described) {
      description = described;
    } else if (catalogErrors.length === 1) {
      description = catalogErrors[0].message;
    } else if (catalogErrors.length > 1) {
      description = catalogErrors.map((error) => error.message).join("; ");
    } else if (status === "200") {
      description = "OK";
    } else if (status === "201") {
      description = "Created";
    } else if (status === "204") {
      description = "No Content";
    } else if (status === "400") {
      description = "Bad Request";
    } else if (status === "401") {
      description = "Unauthorized";
    } else if (status === "403") {
      description = "Forbidden";
    } else if (status === "404") {
      description = "Not Found";
    } else if (status === "500") {
      description = "Internal Server Error";
    } else {
      description = `HTTP ${status}`;
    }

    const examples = examplesFromCatalogErrors(catalogErrors);
    operation.responses[status] = {
      description,
      content:
        !hasSchema || status === "204"
          ? undefined
          : {
              [state.jsonMediaType]: {
                schema: schemaRef,
                ...(examples ? { examples } : {}),
              },
            },
    };
  }
}

type CatalogErrorForOpenAPI = {
  key: string;
  code: string;
  status: number;
  message: string;
  details?: unknown;
};

function getCatalogErrorsForStatus(
  contract: AnyContract,
  status: number,
): CatalogErrorForOpenAPI[] {
  const errors = contract.metadata?.errors;
  if (typeof errors !== "object" || errors === null) return [];

  return Object.entries(errors)
    .map(([key, error]) => {
      if (
        typeof error !== "object" ||
        error === null ||
        typeof (error as { code?: unknown }).code !== "string" ||
        typeof (error as { status?: unknown }).status !== "number" ||
        typeof (error as { message?: unknown }).message !== "string"
      ) {
        return undefined;
      }

      const catalogError: CatalogErrorForOpenAPI = {
        key,
        code: (error as { code: string }).code,
        status: (error as { status: number }).status,
        message: (error as { message: string }).message,
      };
      const details = (error as { details?: unknown }).details;
      if (details !== undefined) {
        catalogError.details = details;
      }

      return catalogError;
    })
    .filter(
      (error): error is CatalogErrorForOpenAPI =>
        error !== undefined && error.status === status,
    );
}

function catalogErrorResponseSchemaRef(
  errors: CatalogErrorForOpenAPI[],
  nameHint: string,
  state: GeneratorState,
): SchemaObject | ReferenceObject {
  const schema =
    errors.length === 1
      ? catalogErrorResponseSchema(errors[0], nameHint, state)
      : {
          oneOf: errors.map((error) =>
            catalogErrorResponseSchema(
              error,
              `${nameHint}_${error.key}`,
              state,
            ),
          ),
        };

  if (!state.components.schemas) {
    state.components.schemas = {};
  }

  const schemaName = normalizeSchemaName(nameHint, state);
  if (!state.components.schemas[schemaName]) {
    state.components.schemas[schemaName] = schema;
  }

  return { $ref: `#/components/schemas/${schemaName}` };
}

function catalogErrorResponseSchema(
  error: CatalogErrorForOpenAPI,
  nameHint: string,
  state: GeneratorState,
): SchemaObject {
  return {
    type: "object",
    properties: {
      code: { type: "string", const: error.code },
      message: { type: "string" },
      ...(error.details
        ? {
            details: schemaToSchemaRef(
              error.details,
              `${nameHint}_details`,
              state,
              "output",
            ),
          }
        : { details: {} }),
      requestId: { type: "string" },
    },
    required: ["code", "message"],
  };
}

function examplesFromCatalogErrors(
  errors: CatalogErrorForOpenAPI[],
): Record<string, { summary?: string; value?: unknown }> | undefined {
  if (errors.length === 0) return undefined;

  return Object.fromEntries(
    errors.map((error) => [
      normalizeExampleKey(error.key),
      {
        summary: error.message,
        value: {
          code: error.code,
          message: error.message,
        },
      },
    ]),
  );
}

function normalizeExampleKey(key: string): string {
  const normalized = key.replace(/[^A-Za-z0-9._-]/g, "_");
  return normalized || "error";
}

/**
 * Convert a validation schema to a JSON Schema reference, registering it in components.
 */
function schemaToConvertedSchemaRef(
  schema: unknown,
  nameHint: string,
  state: GeneratorState,
  io: SchemaIO,
): SchemaObject | ReferenceObject {
  // Ensure schemas object exists
  if (!state.components.schemas) {
    state.components.schemas = {};
  }

  const schemaName = normalizeSchemaName(nameHint, state);

  if (!state.components.schemas[schemaName]) {
    const converter = state.schemaConverters.find((candidate) =>
      candidate.canConvert(schema),
    );

    if (!converter) {
      throw new Error(
        `Unable to convert schema "${nameHint}" to OpenAPI. ` +
          "Pass a schemaConverters entry to contractsToOpenAPI for this schema library.",
      );
    }

    const jsonSchema = converter.toJSONSchema(schema, {
      nameHint,
      io,
    });

    state.components.schemas[schemaName] = jsonSchema;
  }

  return { $ref: `#/components/schemas/${schemaName}` };
}

function schemaToSchemaRef(
  schema: unknown,
  nameHint: string,
  state: GeneratorState,
  io: SchemaIO,
): SchemaObject | ReferenceObject {
  if (schema === STANDARD_ERROR_RESPONSE_SCHEMA) {
    return standardErrorResponseSchemaRef(nameHint, state);
  }

  return schemaToConvertedSchemaRef(schema, nameHint, state, io);
}

function standardErrorResponseSchemaRef(
  nameHint: string,
  state: GeneratorState,
): ReferenceObject {
  if (!state.components.schemas) {
    state.components.schemas = {};
  }

  const schemaName = normalizeSchemaName(nameHint, state);
  if (!state.components.schemas[schemaName]) {
    state.components.schemas[schemaName] = {
      type: "object",
      properties: {
        code: { type: "string" },
        message: { type: "string" },
        details: {},
        requestId: { type: "string" },
      },
      required: ["code", "message"],
    };
  }

  return { $ref: `#/components/schemas/${schemaName}` };
}

/**
 * Normalize schema name for use in components.
 * Appends a counter suffix only when the base name already exists.
 */
function normalizeSchemaName(name: string, state: GeneratorState): string {
  const base = name.replace(/[^A-Za-z0-9]/g, "_");

  // Check if base name is already used
  if (!state.components.schemas?.[base]) {
    return base;
  }

  // Find a unique name by appending a counter
  let counter = 1;
  let uniqueName = `${base}_${counter}`;
  while (state.components.schemas[uniqueName]) {
    counter++;
    uniqueName = `${base}_${counter}`;
  }

  return uniqueName;
}
