import * as fs from 'node:fs';
import * as path from 'node:path';
import * as ts from 'typescript';
import {
  applyDescriptions,
  collectRuntimeDescriptions,
  tryImportRouter,
  type RuntimeDescriptions,
} from './schemaExtraction';
import type {
  Document,
  OperationObject,
  PathItemObject,
  PathsObject,
  SchemaObject,
} from './types';

interface ProcedureInfo {
  path: string;
  type: 'query' | 'mutation' | 'subscription';
  inputSchema: SchemaObject | null;
  outputSchema: SchemaObject | null;
  description?: string;
}

/** State extracted from the router's root config. */
interface RouterMeta {
  errorSchema: SchemaObject | null;
  schemas?: Record<string, SchemaObject>;
}

export interface GenerateOptions {
  /**
   * The name of the exported router symbol.
   * @default 'AppRouter'
   */
  exportName?: string;
  /** Title for the generated OpenAPI `info` object. */
  title?: string;
  /** Version string for the generated OpenAPI `info` object. */
  version?: string;
}

// ---------------------------------------------------------------------------
// Flag helpers
// ---------------------------------------------------------------------------

const PRIMITIVE_FLAGS =
  ts.TypeFlags.String |
  ts.TypeFlags.Number |
  ts.TypeFlags.Boolean |
  ts.TypeFlags.StringLiteral |
  ts.TypeFlags.NumberLiteral |
  ts.TypeFlags.BooleanLiteral;

function hasFlag(type: ts.Type, flag: ts.TypeFlags): boolean {
  return (type.getFlags() & flag) !== 0;
}

function isPrimitive(type: ts.Type): boolean {
  return hasFlag(type, PRIMITIVE_FLAGS);
}

function isObjectType(type: ts.Type): boolean {
  return hasFlag(type, ts.TypeFlags.Object);
}

function isOptionalSymbol(sym: ts.Symbol): boolean {
  return (sym.flags & ts.SymbolFlags.Optional) !== 0;
}

// ---------------------------------------------------------------------------
// JSON Schema conversion — shared state
// ---------------------------------------------------------------------------

/** Shared state threaded through the type-to-schema recursion. */
interface SchemaCtx {
  checker: ts.TypeChecker;
  visited: Set<ts.Type>;
  /** Collected named schemas for components/schemas. */
  schemas: Record<string, SchemaObject>;
  /** Map from TS type identity to its registered schema name. */
  typeToRef: Map<ts.Type, string>;
}

// ---------------------------------------------------------------------------
// Brand unwrapping
// ---------------------------------------------------------------------------

/**
 * If `type` is a branded intersection (primitive & object), return just the
 * primitive part.  Otherwise return the type as-is.
 */
function unwrapBrand(type: ts.Type): ts.Type {
  if (!type.isIntersection()) {
    return type;
  }
  const primitives = type.types.filter(isPrimitive);
  const hasObject = type.types.some(isObjectType);
  const [first] = primitives;
  if (first && hasObject) {
    return first;
  }
  return type;
}

// ---------------------------------------------------------------------------
// Schema naming helpers
// ---------------------------------------------------------------------------

const ANONYMOUS_NAMES = new Set(['__type', '__object', 'Object', '']);
const INTERNAL_COMPUTED_PROPERTY_SYMBOL = /^__@.*@\d+$/;

/** Try to determine a meaningful name for a TS type (type alias or interface). */
function getTypeName(type: ts.Type): string | null {
  const aliasName = type.aliasSymbol?.getName();
  if (aliasName && !ANONYMOUS_NAMES.has(aliasName)) {
    return aliasName;
  }
  const symName = type.getSymbol()?.getName();
  if (symName && !ANONYMOUS_NAMES.has(symName) && !symName.startsWith('__')) {
    return symName;
  }
  return null;
}

// Skips asyncGenerator and branded symbols etc when creating types
// Symbols can't be serialised
function shouldSkipPropertySymbol(prop: ts.Symbol): boolean {
  return (
    prop.declarations?.some((declaration) => {
      const declarationName = ts.getNameOfDeclaration(declaration);
      if (!declarationName || !ts.isComputedPropertyName(declarationName)) {
        return false;
      }

      return INTERNAL_COMPUTED_PROPERTY_SYMBOL.test(prop.getName());
    }) ?? false
  );
}

function getReferencedSchema(
  schema: SchemaObject | null,
  schemas: Record<string, SchemaObject>,
): SchemaObject | null {
  const ref = schema?.$ref;
  if (!ref?.startsWith('#/components/schemas/')) {
    return schema;
  }

  const refName = ref.slice('#/components/schemas/'.length);
  return refName ? (schemas[refName] ?? null) : schema;
}

function ensureUniqueName(
  name: string,
  existing: Record<string, unknown>,
): string {
  if (!(name in existing)) {
    return name;
  }
  let i = 2;
  while (`${name}${i}` in existing) {
    i++;
  }
  return `${name}${i}`;
}

function schemaRef(name: string): SchemaObject {
  return { $ref: `#/components/schemas/${name}` };
}

function isSelfSchemaRef(schema: SchemaObject, name: string): boolean {
  return schema.$ref === schemaRef(name).$ref;
}

function isNonEmptySchema(s: SchemaObject): boolean {
  for (const _ in s) return true;
  return false;
}

// ---------------------------------------------------------------------------
// Type → JSON Schema (with component extraction)
// ---------------------------------------------------------------------------

/**
 * Convert a TS type to a JSON Schema.  If the type has been pre-registered
 * (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref`
 * is returned instead of an inline schema.
 *
 * Named types (type aliases, interfaces) are auto-registered before conversion
 * so that recursive references (including through unions and intersections)
 * resolve to a `$ref` instead of causing infinite recursion.
 */
function typeToJsonSchema(
  type: ts.Type,
  ctx: SchemaCtx,
  depth = 0,
): SchemaObject {
  // If this type is already registered as a named schema, return a $ref.
  const existingRef = ctx.typeToRef.get(type);
  if (existingRef) {
    const storedSchema = ctx.schemas[existingRef];
    if (
      storedSchema &&
      (isNonEmptySchema(storedSchema) || ctx.visited.has(type))
    ) {
      return schemaRef(existingRef);
    }

    // First encounter for a pre-registered placeholder: convert once, but keep
    // returning $ref for recursive edges while the type is actively visiting.
    ctx.schemas[existingRef] = storedSchema ?? {};
    const schema = convertTypeToSchema(type, ctx, depth);
    if (!isSelfSchemaRef(schema, existingRef)) {
      ctx.schemas[existingRef] = schema;
    }
    return schemaRef(existingRef);
  }

  const schema = convertTypeToSchema(type, ctx, depth);

  // If a recursive reference was detected during conversion (via handleCyclicRef
  // or convertPlainObject's auto-registration), the type is now registered in
  // typeToRef.  If the stored schema is still the empty placeholder, fill it in
  // with the actual converted schema.  Either way, return a $ref.
  const postConvertRef = ctx.typeToRef.get(type);
  if (postConvertRef) {
    const stored = ctx.schemas[postConvertRef];
    if (
      stored &&
      !isNonEmptySchema(stored) &&
      !isSelfSchemaRef(schema, postConvertRef)
    ) {
      ctx.schemas[postConvertRef] = schema;
    }
    return schemaRef(postConvertRef);
  }

  // Extract JSDoc from type alias symbol (e.g. `/** desc */ type Foo = string`)
  if (!schema.description && !schema.$ref && type.aliasSymbol) {
    const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker);
    if (aliasJsDoc) {
      schema.description = aliasJsDoc;
    }
  }

  return schema;
}

// ---------------------------------------------------------------------------
// Cyclic reference handling
// ---------------------------------------------------------------------------

/**
 * When we encounter a type we're already visiting, it's recursive.
 * Register it as a named schema and return a $ref.
 */
function handleCyclicRef(type: ts.Type, ctx: SchemaCtx): SchemaObject {
  let refName = ctx.typeToRef.get(type);
  if (!refName) {
    const name = getTypeName(type) ?? 'RecursiveType';
    refName = ensureUniqueName(name, ctx.schemas);
    ctx.typeToRef.set(type, refName);
    ctx.schemas[refName] = {}; // placeholder — filled by the outer call
  }
  return schemaRef(refName);
}

// ---------------------------------------------------------------------------
// Primitive & literal type conversion
// ---------------------------------------------------------------------------

function convertPrimitiveOrLiteral(
  type: ts.Type,
  flags: ts.TypeFlags,
  checker: ts.TypeChecker,
): SchemaObject | null {
  if (flags & ts.TypeFlags.String) {
    return { type: 'string' };
  }
  if (flags & ts.TypeFlags.Number) {
    return { type: 'number' };
  }
  if (flags & ts.TypeFlags.Boolean) {
    return { type: 'boolean' };
  }
  if (flags & ts.TypeFlags.Null) {
    return { type: 'null' };
  }
  if (flags & ts.TypeFlags.Undefined) {
    return {};
  }
  if (flags & ts.TypeFlags.Void) {
    return {};
  }
  if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) {
    return {};
  }
  if (flags & ts.TypeFlags.Never) {
    return { not: {} };
  }
  if (flags & ts.TypeFlags.BigInt || flags & ts.TypeFlags.BigIntLiteral) {
    return { type: 'integer', format: 'bigint' };
  }

  if (flags & ts.TypeFlags.StringLiteral) {
    return { type: 'string', const: (type as ts.StringLiteralType).value };
  }
  if (flags & ts.TypeFlags.NumberLiteral) {
    return { type: 'number', const: (type as ts.NumberLiteralType).value };
  }
  if (flags & ts.TypeFlags.BooleanLiteral) {
    const isTrue = checker.typeToString(type) === 'true';
    return { type: 'boolean', const: isTrue };
  }

  return null;
}

// ---------------------------------------------------------------------------
// Union type conversion
// ---------------------------------------------------------------------------

function convertUnionType(
  type: ts.UnionType,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  const members = type.types;

  // Strip undefined / void members (they make the field optional, not typed)
  const defined = members.filter(
    (m) => !hasFlag(m, ts.TypeFlags.Undefined | ts.TypeFlags.Void),
  );
  if (defined.length === 0) {
    return {};
  }

  const hasNull = defined.some((m) => hasFlag(m, ts.TypeFlags.Null));
  const nonNull = defined.filter((m) => !hasFlag(m, ts.TypeFlags.Null));

  // TypeScript represents `boolean` as `true | false`.  Collapse boolean
  // literal pairs back into a single boolean, even when mixed with other types.
  // e.g. `string | true | false` → treat as `string | boolean`
  const boolLiterals = nonNull.filter((m) =>
    hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral),
  );
  const hasBoolPair =
    boolLiterals.length === 2 &&
    boolLiterals.some(
      (m) => ctx.checker.typeToString(unwrapBrand(m)) === 'true',
    ) &&
    boolLiterals.some(
      (m) => ctx.checker.typeToString(unwrapBrand(m)) === 'false',
    );

  // Build the effective non-null members, collapsing boolean literal pairs
  const effective = hasBoolPair
    ? nonNull.filter(
        (m) => !hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral),
      )
    : nonNull;

  // Pure boolean (or boolean | null) — no other types
  if (hasBoolPair && effective.length === 0) {
    return hasNull ? { type: ['boolean', 'null'] } : { type: 'boolean' };
  }

  // Collapse unions of same-type literals into a single `enum` array.
  // e.g. "FOO" | "BAR" → { type: "string", enum: ["FOO", "BAR"] }
  const collapsedEnum = tryCollapseLiteralUnion(effective, hasNull);
  if (collapsedEnum) {
    return collapsedEnum;
  }

  const schemas = effective
    .map((m) => typeToJsonSchema(m, ctx, depth + 1))
    .filter(isNonEmptySchema);

  // Re-inject the collapsed boolean
  if (hasBoolPair) {
    schemas.push({ type: 'boolean' });
  }

  if (hasNull) {
    schemas.push({ type: 'null' });
  }

  if (schemas.length === 0) {
    return {};
  }

  const [firstSchema] = schemas;
  if (schemas.length === 1 && firstSchema !== undefined) {
    return firstSchema;
  }

  // When all schemas are simple type-only schemas (no other properties),
  // collapse into a single `type` array. e.g. string | null → type: ["string", "null"]
  if (schemas.every(isSimpleTypeSchema)) {
    return { type: schemas.map((s) => s.type as string) };
  }

  // Detect discriminated unions: all oneOf members are objects sharing a common
  // required property whose value is a `const`.  If found, add a `discriminator`.
  const discriminatorProp = detectDiscriminatorProperty(schemas);
  if (discriminatorProp) {
    return {
      oneOf: schemas,
      discriminator: { propertyName: discriminatorProp },
    };
  }

  return { oneOf: schemas };
}

/**
 * If every schema in a oneOf is an object with a common required property
 * whose value is a `const`, return that property name.  Otherwise return null.
 */
function detectDiscriminatorProperty(schemas: SchemaObject[]): string | null {
  if (schemas.length < 2) {
    return null;
  }

  // All schemas must be object types with properties
  if (!schemas.every((s) => s.type === 'object' && s.properties)) {
    return null;
  }

  // Find properties that exist in every schema, are required, and have a `const` value
  const first = schemas[0];
  if (!first?.properties) {
    return null;
  }
  const firstProps = Object.keys(first.properties);
  for (const prop of firstProps) {
    const allHaveConst = schemas.every((s) => {
      const propSchema = s.properties?.[prop];
      return (
        propSchema !== undefined &&
        propSchema.const !== undefined &&
        s.required?.includes(prop)
      );
    });
    if (allHaveConst) {
      return prop;
    }
  }

  return null;
}

/** A schema that is just `{ type: "somePrimitive" }` with no other keys. */
function isSimpleTypeSchema(s: SchemaObject): boolean {
  const keys = Object.keys(s);
  return keys.length === 1 && keys[0] === 'type' && typeof s.type === 'string';
}

/**
 * If every non-null member is a string or number literal of the same kind,
 * collapse them into a single `{ type, enum }` schema.
 */
function tryCollapseLiteralUnion(
  nonNull: ts.Type[],
  hasNull: boolean,
): SchemaObject | null {
  if (nonNull.length <= 1) {
    return null;
  }

  const allLiterals = nonNull.every((m) =>
    hasFlag(m, ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral),
  );
  if (!allLiterals) {
    return null;
  }

  const [first] = nonNull;
  if (!first) {
    return null;
  }

  const isString = hasFlag(first, ts.TypeFlags.StringLiteral);
  const targetFlag = isString
    ? ts.TypeFlags.StringLiteral
    : ts.TypeFlags.NumberLiteral;
  const allSameKind = nonNull.every((m) => hasFlag(m, targetFlag));
  if (!allSameKind) {
    return null;
  }

  const values = nonNull.map((m) =>
    isString
      ? (m as ts.StringLiteralType).value
      : (m as ts.NumberLiteralType).value,
  );
  const baseType = isString ? 'string' : 'number';
  return {
    type: hasNull ? [baseType, 'null'] : baseType,
    enum: values,
  };
}

// ---------------------------------------------------------------------------
// Intersection type conversion
// ---------------------------------------------------------------------------

function convertIntersectionType(
  type: ts.IntersectionType,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  // Branded types (e.g. z.string().brand<'X'>()) appear as an intersection of
  // a primitive with a phantom object.  Strip the object members — they are
  // always brand metadata.
  const hasPrimitiveMember = type.types.some(isPrimitive);
  const nonBrand = hasPrimitiveMember
    ? type.types.filter((m) => !isObjectType(m))
    : type.types;

  const schemas = nonBrand
    .map((m) => typeToJsonSchema(m, ctx, depth + 1))
    .filter(isNonEmptySchema);

  if (schemas.length === 0) {
    return {};
  }
  const [onlySchema] = schemas;
  if (schemas.length === 1 && onlySchema !== undefined) {
    return onlySchema;
  }

  // When all members are plain inline object schemas (no $ref), merge them
  // into a single object instead of wrapping in allOf.
  if (schemas.every(isInlineObjectSchema)) {
    return mergeObjectSchemas(schemas);
  }

  return { allOf: schemas };
}

/** True when the schema is an inline `{ type: "object", ... }` (not a $ref). */
function isInlineObjectSchema(s: SchemaObject): boolean {
  return s.type === 'object' && !s.$ref;
}

/**
 * Merge multiple `{ type: "object" }` schemas into one.
 * Falls back to `allOf` if any property names conflict across schemas.
 */
function mergeObjectSchemas(schemas: SchemaObject[]): SchemaObject {
  // Check for property name conflicts before merging.
  const seen = new Set<string>();
  for (const s of schemas) {
    if (s.properties) {
      for (const prop of Object.keys(s.properties)) {
        if (seen.has(prop)) {
          // Conflicting property — fall back to allOf to preserve both definitions.
          return { allOf: schemas };
        }
        seen.add(prop);
      }
    }
  }

  const properties: Record<string, SchemaObject> = {};
  const required: string[] = [];
  let additionalProperties: SchemaObject | boolean | undefined;

  for (const s of schemas) {
    if (s.properties) {
      Object.assign(properties, s.properties);
    }
    if (s.required) {
      required.push(...s.required);
    }
    if (s.additionalProperties !== undefined) {
      additionalProperties = s.additionalProperties;
    }
  }

  const result: SchemaObject = { type: 'object' };
  if (Object.keys(properties).length > 0) {
    result.properties = properties;
  }
  if (required.length > 0) {
    result.required = required;
  }
  if (additionalProperties !== undefined) {
    result.additionalProperties = additionalProperties;
  }
  return result;
}

// ---------------------------------------------------------------------------
// Object type conversion
// ---------------------------------------------------------------------------

function convertWellKnownType(
  type: ts.Type,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject | null {
  const symName = type.getSymbol()?.getName();
  if (symName === 'Date') {
    return { type: 'string', format: 'date-time' };
  }
  if (symName === 'Uint8Array' || symName === 'Buffer') {
    return { type: 'string', format: 'binary' };
  }

  // Unwrap Promise<T>
  if (symName === 'Promise') {
    const [inner] = ctx.checker.getTypeArguments(type as ts.TypeReference);
    return inner ? typeToJsonSchema(inner, ctx, depth + 1) : {};
  }

  return null;
}

function convertArrayType(
  type: ts.Type,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  const [elem] = ctx.checker.getTypeArguments(type as ts.TypeReference);
  const schema: SchemaObject = { type: 'array' };
  if (elem) {
    schema.items = typeToJsonSchema(elem, ctx, depth + 1);
  }
  return schema;
}

function convertTupleType(
  type: ts.Type,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  const args = ctx.checker.getTypeArguments(type as ts.TypeReference);
  const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1));
  return {
    type: 'array',
    prefixItems: schemas,
    items: false,
    minItems: args.length,
    maxItems: args.length,
  };
}

function convertPlainObject(
  type: ts.Type,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  const { checker } = ctx;
  const stringIndexType = type.getStringIndexType();
  const typeProps = type.getProperties();

  // Pure index-signature Record type (no named props)
  if (typeProps.length === 0 && stringIndexType) {
    return {
      type: 'object',
      additionalProperties: typeToJsonSchema(stringIndexType, ctx, depth + 1),
    };
  }

  // Auto-register types with a meaningful TS name BEFORE converting
  // properties, so that circular or shared refs discovered during recursion
  // resolve to a $ref via the `typeToJsonSchema` wrapper.
  let autoRegName: string | null = null;
  const tsName = getTypeName(type);
  const isNamedUnregisteredType =
    tsName !== null && typeProps.length > 0 && !ctx.typeToRef.has(type);
  if (isNamedUnregisteredType) {
    autoRegName = ensureUniqueName(tsName, ctx.schemas);
    ctx.typeToRef.set(type, autoRegName);
    ctx.schemas[autoRegName] = {}; // placeholder for circular ref guard
  }

  ctx.visited.add(type);
  const properties: Record<string, SchemaObject> = {};
  const required: string[] = [];

  for (const prop of typeProps) {
    if (shouldSkipPropertySymbol(prop)) {
      continue;
    }

    const propType = checker.getTypeOfSymbol(prop);
    const propSchema = typeToJsonSchema(propType, ctx, depth + 1);

    // Extract JSDoc comment from the property symbol as a description
    const jsDoc = getJsDocComment(prop, checker);
    if (jsDoc && !propSchema.description && !propSchema.$ref) {
      propSchema.description = jsDoc;
    }

    properties[prop.name] = propSchema;
    if (!isOptionalSymbol(prop)) {
      required.push(prop.name);
    }
  }

  ctx.visited.delete(type);

  const result: SchemaObject = { type: 'object' };
  if (Object.keys(properties).length > 0) {
    result.properties = properties;
  }
  if (required.length > 0) {
    result.required = required;
  }
  if (stringIndexType) {
    result.additionalProperties = typeToJsonSchema(
      stringIndexType,
      ctx,
      depth + 1,
    );
  } else if (Object.keys(properties).length > 0) {
    result.additionalProperties = false;
  }

  // autoRegName covers named types (early-registered).  For anonymous
  // recursive types, a recursive call may have registered this type during
  // property conversion — check typeToRef as a fallback.
  const registeredName = autoRegName ?? ctx.typeToRef.get(type);
  if (registeredName) {
    ctx.schemas[registeredName] = result;
    return schemaRef(registeredName);
  }

  return result;
}

function convertObjectType(
  type: ts.Type,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  const wellKnown = convertWellKnownType(type, ctx, depth);
  if (wellKnown) {
    return wellKnown;
  }

  if (ctx.checker.isArrayType(type)) {
    return convertArrayType(type, ctx, depth);
  }
  if (ctx.checker.isTupleType(type)) {
    return convertTupleType(type, ctx, depth);
  }

  return convertPlainObject(type, ctx, depth);
}

// ---------------------------------------------------------------------------
// Core dispatcher
// ---------------------------------------------------------------------------

/** Core type-to-schema conversion (no ref handling). */
function convertTypeToSchema(
  type: ts.Type,
  ctx: SchemaCtx,
  depth: number,
): SchemaObject {
  if (ctx.visited.has(type)) {
    return handleCyclicRef(type, ctx);
  }

  const flags = type.getFlags();

  const primitive = convertPrimitiveOrLiteral(type, flags, ctx.checker);
  if (primitive) {
    return primitive;
  }

  if (type.isUnion()) {
    ctx.visited.add(type);
    const result = convertUnionType(type, ctx, depth);
    ctx.visited.delete(type);
    return result;
  }
  if (type.isIntersection()) {
    ctx.visited.add(type);
    const result = convertIntersectionType(type, ctx, depth);
    ctx.visited.delete(type);
    return result;
  }
  if (isObjectType(type)) {
    return convertObjectType(type, ctx, depth);
  }

  return {};
}

// ---------------------------------------------------------------------------
// Router / procedure type walker
// ---------------------------------------------------------------------------

/** State shared across the router-walk recursion. */
interface WalkCtx {
  procedures: ProcedureInfo[];
  seen: Set<ts.Type>;
  schemaCtx: SchemaCtx;
  /** Runtime descriptions keyed by procedure path (when a router instance is available). */
  runtimeDescriptions: Map<string, RuntimeDescriptions>;
}

/**
 * Inspect `_def.type` and return the procedure type string, or null if this is
 * not a procedure (e.g. a nested router).
 */
function getProcedureTypeName(
  defType: ts.Type,
  checker: ts.TypeChecker,
): ProcedureInfo['type'] | null {
  const typeSym = defType.getProperty('type');
  if (!typeSym) {
    return null;
  }
  const typeType = checker.getTypeOfSymbol(typeSym);
  const raw = checker.typeToString(typeType).replace(/['"]/g, '');
  if (raw === 'query' || raw === 'mutation' || raw === 'subscription') {
    return raw;
  }
  return null;
}

function isVoidLikeInput(inputType: ts.Type | null): boolean {
  if (!inputType) {
    return true;
  }

  const isVoidOrUndefinedOrNever = hasFlag(
    inputType,
    ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never,
  );
  if (isVoidOrUndefinedOrNever) {
    return true;
  }

  const isUnionOfVoids =
    inputType.isUnion() &&
    inputType.types.every((t) =>
      hasFlag(t, ts.TypeFlags.Void | ts.TypeFlags.Undefined),
    );
  return isUnionOfVoids;
}

interface ProcedureDef {
  defType: ts.Type;
  typeName: string;
  path: string;
  description?: string;
  symbol: ts.Symbol;
}

function shouldIncludeProcedureInOpenAPI(type: ProcedureInfo['type']): boolean {
  return type !== 'subscription';
}

function getProcedureInputTypeName(type: ts.Type, path: string): string {
  const directName = getTypeName(type);
  if (directName) {
    return directName;
  }

  for (const sym of [type.aliasSymbol, type.getSymbol()].filter(
    (candidate): candidate is ts.Symbol => !!candidate,
  )) {
    for (const declaration of sym.declarations ?? []) {
      const declarationName = ts.getNameOfDeclaration(declaration)?.getText();
      if (
        declarationName &&
        !ANONYMOUS_NAMES.has(declarationName) &&
        !declarationName.startsWith('__')
      ) {
        return declarationName;
      }
    }
  }

  const fallbackName = path
    .split('.')
    .filter(Boolean)
    .map((segment) =>
      segment
        .split(/[^A-Za-z0-9]+/)
        .filter(Boolean)
        .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
        .join(''),
    )
    .join('');

  return `${fallbackName || 'Procedure'}Input`;
}

function isUnknownLikeType(type: ts.Type): boolean {
  return hasFlag(type, ts.TypeFlags.Unknown | ts.TypeFlags.Any);
}

function isCollapsedProcedureInputType(type: ts.Type): boolean {
  return (
    isUnknownLikeType(type) ||
    (isObjectType(type) &&
      type.getProperties().length === 0 &&
      !type.getStringIndexType())
  );
}

function recoverProcedureInputType(
  def: ProcedureDef,
  checker: ts.TypeChecker,
): ts.Type | null {
  let initializer: ts.Expression | null = null;
  for (const declaration of def.symbol.declarations ?? []) {
    if (ts.isPropertyAssignment(declaration)) {
      initializer = declaration.initializer;
      break;
    }
    if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
      initializer = declaration.initializer;
      break;
    }
  }
  if (!initializer) {
    return null;
  }

  let recovered: ts.Type | null = null;
  // Walk the builder chain and keep the last `.input(...)` parser output type.
  const visit = (expr: ts.Expression): void => {
    if (!ts.isCallExpression(expr)) {
      return;
    }

    const callee = expr.expression;
    if (!ts.isPropertyAccessExpression(callee)) {
      return;
    }

    visit(callee.expression);
    if (callee.name.text !== 'input') {
      return;
    }

    const [parserExpr] = expr.arguments;
    if (!parserExpr) {
      return;
    }

    const parserType = checker.getTypeAtLocation(parserExpr);
    const standardSym = parserType.getProperty('~standard');
    if (!standardSym) {
      return;
    }

    const standardType = checker.getTypeOfSymbolAtLocation(
      standardSym,
      parserExpr,
    );
    const typesSym = standardType.getProperty('types');
    if (!typesSym) {
      return;
    }

    const typesType = checker.getNonNullableType(
      checker.getTypeOfSymbolAtLocation(typesSym, parserExpr),
    );
    const outputSym = typesType.getProperty('output');
    if (!outputSym) {
      return;
    }

    const outputType = checker.getTypeOfSymbolAtLocation(outputSym, parserExpr);
    if (!isUnknownLikeType(outputType)) {
      recovered = outputType;
    }
  };
  visit(initializer);

  return recovered;
}

function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
  const { schemaCtx } = ctx;
  const { checker } = schemaCtx;

  const $typesSym = def.defType.getProperty('$types');
  if (!$typesSym) {
    return;
  }
  const $typesType = checker.getTypeOfSymbol($typesSym);

  const inputSym = $typesType.getProperty('input');
  const outputSym = $typesType.getProperty('output');

  const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null;
  const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null;
  const resolvedInputType =
    inputType && isCollapsedProcedureInputType(inputType)
      ? (recoverProcedureInputType(def, checker) ?? inputType)
      : inputType;

  let inputSchema: SchemaObject | null = null;
  if (!resolvedInputType || isVoidLikeInput(resolvedInputType)) {
    // null is fine
  } else {
    // Pre-register recovered parser output types so recursive edges resolve to a
    // stable component ref instead of collapsing into `{}`.
    const ensureRecoveredInputRegistration = (type: ts.Type): void => {
      if (schemaCtx.typeToRef.has(type)) {
        return;
      }

      const refName = ensureUniqueName(
        getProcedureInputTypeName(type, def.path),
        schemaCtx.schemas,
      );
      schemaCtx.typeToRef.set(type, refName);
      schemaCtx.schemas[refName] = {};
    };

    if (resolvedInputType !== inputType) {
      ensureRecoveredInputRegistration(resolvedInputType);
    }

    const initialSchema = typeToJsonSchema(resolvedInputType, schemaCtx);
    if (
      !isNonEmptySchema(initialSchema) &&
      !schemaCtx.typeToRef.has(resolvedInputType)
    ) {
      ensureRecoveredInputRegistration(resolvedInputType);
      inputSchema = typeToJsonSchema(resolvedInputType, schemaCtx);
    } else {
      inputSchema = initialSchema;
    }
  }

  const outputSchema: SchemaObject | null = outputType
    ? typeToJsonSchema(outputType, schemaCtx)
    : null;

  // Overlay extracted schema descriptions onto the type-checker-generated schemas.
  const runtimeDescs = ctx.runtimeDescriptions.get(def.path);
  if (runtimeDescs) {
    const resolvedInputSchema = getReferencedSchema(
      inputSchema,
      schemaCtx.schemas,
    );
    const resolvedOutputSchema = getReferencedSchema(
      outputSchema,
      schemaCtx.schemas,
    );

    if (resolvedInputSchema && runtimeDescs.input) {
      applyDescriptions(
        resolvedInputSchema,
        runtimeDescs.input,
        schemaCtx.schemas,
      );
    }
    if (resolvedOutputSchema && runtimeDescs.output) {
      applyDescriptions(
        resolvedOutputSchema,
        runtimeDescs.output,
        schemaCtx.schemas,
      );
    }
  }

  ctx.procedures.push({
    path: def.path,
    type: def.typeName as 'query' | 'mutation' | 'subscription',
    inputSchema,
    outputSchema,
    description: def.description,
  });
}

/** Extract the JSDoc comment text from a symbol, if any. */
function getJsDocComment(
  sym: ts.Symbol,
  checker: ts.TypeChecker,
): string | undefined {
  const normalize = (filePath: string): string => filePath.replace(/\\/g, '/');

  const declarations = sym.declarations ?? [];
  const isExternalNodeModulesDeclaration =
    declarations.length > 0 &&
    declarations.every((declaration) => {
      const sourceFile = declaration.getSourceFile();
      if (!sourceFile.isDeclarationFile) {
        return false;
      }

      const declarationPath = normalize(sourceFile.fileName);
      if (!declarationPath.includes('/node_modules/')) {
        return false;
      }

      try {
        const realPath = normalize(fs.realpathSync.native(sourceFile.fileName));
        // Keep JSDoc for workspace packages linked into node_modules
        // (e.g. monorepos using pnpm/yarn workspaces). The resolved target
        // may sit outside the current cwd, so avoid cwd-based checks here.
        if (!realPath.includes('/node_modules/')) {
          return false;
        }
      } catch {
        // Fall back to treating the declaration as external.
      }

      return true;
    });
  if (isExternalNodeModulesDeclaration) {
    return undefined;
  }

  const parts = sym.getDocumentationComment(checker);
  if (parts.length === 0) {
    return undefined;
  }
  const text = parts.map((p) => p.text).join('');
  return text || undefined;
}

interface WalkTypeOpts {
  type: ts.Type;
  ctx: WalkCtx;
  currentPath: string;
  description?: string;
  symbol?: ts.Symbol;
}

function walkType(opts: WalkTypeOpts): void {
  const { type, ctx, currentPath, description, symbol } = opts;
  if (ctx.seen.has(type)) {
    return;
  }

  const defSym = type.getProperty('_def');

  if (!defSym) {
    // No `_def` — this is a plain RouterRecord or an unrecognised type.
    // Walk its own properties so nested procedures are found.
    if (isObjectType(type)) {
      ctx.seen.add(type);
      walkRecord(type, ctx, currentPath);
      ctx.seen.delete(type);
    }
    return;
  }

  const { checker } = ctx.schemaCtx;
  const defType = checker.getTypeOfSymbol(defSym);

  const procedureTypeName = getProcedureTypeName(defType, checker);
  if (procedureTypeName) {
    if (!shouldIncludeProcedureInOpenAPI(procedureTypeName)) {
      return;
    }

    extractProcedure(
      {
        defType,
        typeName: procedureTypeName,
        path: currentPath,
        description,
        symbol: symbol ?? type.getSymbol() ?? defSym,
      },
      ctx,
    );
    return;
  }

  // Router? (_def.router === true)
  const routerSym = defType.getProperty('router');
  if (!routerSym) {
    return;
  }

  const isRouter =
    checker.typeToString(checker.getTypeOfSymbol(routerSym)) === 'true';
  if (!isRouter) {
    return;
  }

  const recordSym = defType.getProperty('record');
  if (!recordSym) {
    return;
  }

  ctx.seen.add(type);
  const recordType = checker.getTypeOfSymbol(recordSym);
  walkRecord(recordType, ctx, currentPath);
  ctx.seen.delete(type);
}

function walkRecord(recordType: ts.Type, ctx: WalkCtx, prefix: string): void {
  for (const prop of recordType.getProperties()) {
    const propType = ctx.schemaCtx.checker.getTypeOfSymbol(prop);
    const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name;
    const description = getJsDocComment(prop, ctx.schemaCtx.checker);
    walkType({
      type: propType,
      ctx,
      currentPath: fullPath,
      description,
      symbol: prop,
    });
  }
}

// ---------------------------------------------------------------------------
// TypeScript program helpers
// ---------------------------------------------------------------------------

function loadCompilerOptions(startDir: string): ts.CompilerOptions {
  const configPath = ts.findConfigFile(
    startDir,
    (f) => ts.sys.fileExists(f),
    'tsconfig.json',
  );
  if (!configPath) {
    return {
      target: ts.ScriptTarget.ES2020,
      moduleResolution: ts.ModuleResolutionKind.Bundler,
      skipLibCheck: true,
      noEmit: true,
    };
  }

  const configFile = ts.readConfigFile(configPath, (f) => ts.sys.readFile(f));
  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    path.dirname(configPath),
  );
  const options: ts.CompilerOptions = { ...parsed.options, noEmit: true };

  // `parseJsonConfigFileContent` only returns explicitly-set values.  TypeScript
  // itself infers moduleResolution from `module` at compile time, but we have to
  // do it manually here for the compiler host to resolve imports correctly.
  if (options.moduleResolution === undefined) {
    const mod = options.module;
    if (mod === ts.ModuleKind.Node16 || mod === ts.ModuleKind.NodeNext) {
      options.moduleResolution = ts.ModuleResolutionKind.NodeNext;
    } else if (
      mod === ts.ModuleKind.Preserve ||
      mod === ts.ModuleKind.ES2022 ||
      mod === ts.ModuleKind.ESNext
    ) {
      options.moduleResolution = ts.ModuleResolutionKind.Bundler;
    } else {
      options.moduleResolution = ts.ModuleResolutionKind.Node10;
    }
  }

  return options;
}

// ---------------------------------------------------------------------------
// Error shape extraction
// ---------------------------------------------------------------------------

/**
 * Walk `_def._config.$types.errorShape` on the router type and convert
 * it to a JSON Schema.  Returns `null` when the path cannot be resolved
 * (e.g. older tRPC versions or missing type info).
 */
function extractErrorSchema(
  routerType: ts.Type,
  checker: ts.TypeChecker,
  schemaCtx: SchemaCtx,
): SchemaObject | null {
  const walk = (type: ts.Type, keys: string[]): ts.Type | null => {
    const [head, ...rest] = keys;
    if (!head) {
      return type;
    }
    const sym = type.getProperty(head);
    if (!sym) {
      return null;
    }
    return walk(checker.getTypeOfSymbol(sym), rest);
  };

  const errorShapeType = walk(routerType, [
    '_def',
    '_config',
    '$types',
    'errorShape',
  ]);
  if (!errorShapeType) {
    return null;
  }

  if (hasFlag(errorShapeType, ts.TypeFlags.Any)) {
    return null;
  }

  return typeToJsonSchema(errorShapeType, schemaCtx);
}

// ---------------------------------------------------------------------------
// OpenAPI document builder
// ---------------------------------------------------------------------------

/** Fallback error schema when the router type doesn't expose an error shape. */
const DEFAULT_ERROR_SCHEMA: SchemaObject = {
  type: 'object',
  properties: {
    message: { type: 'string' },
    code: { type: 'string' },
    data: { type: 'object' },
  },
  required: ['message', 'code'],
};

/**
 * Wrap a procedure's output schema in the tRPC success envelope.
 *
 * tRPC HTTP responses are always serialised as:
 *   `{ result: { data: T } }`
 *
 * When the procedure has no output the envelope is still present but
 * the `data` property is omitted.
 */
function wrapInSuccessEnvelope(
  outputSchema: SchemaObject | null,
): SchemaObject {
  const hasOutput = outputSchema !== null && isNonEmptySchema(outputSchema);
  const resultSchema: SchemaObject = {
    type: 'object',
    properties: {
      ...(hasOutput ? { data: outputSchema } : {}),
    },
    ...(hasOutput ? { required: ['data'] } : {}),
  };
  return {
    type: 'object',
    properties: {
      result: resultSchema,
    },
    required: ['result'],
  };
}

function buildProcedureOperation(
  proc: ProcedureInfo,
  method: 'get' | 'post',
): OperationObject {
  const [tag = proc.path] = proc.path.split('.');
  const operation: OperationObject = {
    operationId: proc.path,
    ...(proc.description ? { description: proc.description } : {}),
    tags: [tag],
    responses: {
      '200': {
        description: 'Successful response',
        content: {
          'application/json': {
            schema: wrapInSuccessEnvelope(proc.outputSchema),
          },
        },
      },
      default: { $ref: '#/components/responses/Error' },
    },
  };

  if (proc.inputSchema === null) {
    return operation;
  }

  if (method === 'get') {
    operation.parameters = [
      {
        name: 'input',
        in: 'query',
        required: true,
        // FIXME: OAS 3.1.1 says a parameter MUST use either schema+style OR content, not both.
        // style should be removed here, but hey-api requires it to generate a correct query serializer.
        style: 'deepObject',
        content: { 'application/json': { schema: proc.inputSchema } },
      },
    ];
  } else {
    operation.requestBody = {
      required: true,
      content: { 'application/json': { schema: proc.inputSchema } },
    };
  }

  return operation;
}

function buildOpenAPIDocument(
  procedures: ProcedureInfo[],
  options: GenerateOptions,
  meta: RouterMeta = { errorSchema: null },
): Document {
  const paths: PathsObject = {};

  for (const proc of procedures) {
    if (!shouldIncludeProcedureInOpenAPI(proc.type)) {
      continue;
    }

    const opPath = `/${proc.path}`;
    const method = proc.type === 'query' ? 'get' : 'post';

    const pathItem: PathItemObject = paths[opPath] ?? {};
    paths[opPath] = pathItem;
    pathItem[method] = buildProcedureOperation(
      proc,
      method,
    ) as PathItemObject[typeof method];
  }

  const hasNamedSchemas =
    meta.schemas !== undefined && Object.keys(meta.schemas).length > 0;

  return {
    openapi: '3.1.1',
    jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base',
    info: {
      title: options.title ?? 'tRPC API',
      version: options.version ?? '0.0.0',
    },
    paths,
    components: {
      ...(hasNamedSchemas && meta.schemas ? { schemas: meta.schemas } : {}),
      responses: {
        Error: {
          description: 'Error response',
          content: {
            'application/json': {
              schema: {
                type: 'object',
                properties: {
                  error: meta.errorSchema ?? DEFAULT_ERROR_SCHEMA,
                },
                required: ['error'],
              },
            },
          },
        },
      },
    },
  };
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Analyse the given TypeScript router file using the TypeScript compiler and
 * return an OpenAPI 3.1 document describing all query and mutation procedures.
 *
 * @param routerFilePath - Absolute or relative path to the file that exports
 *   the AppRouter.
 * @param options - Optional generation settings (export name, title, version).
 */
export async function generateOpenAPIDocument(
  routerFilePath: string,
  options: GenerateOptions = {},
): Promise<Document> {
  const resolvedPath = path.resolve(routerFilePath);
  const exportName = options.exportName ?? 'AppRouter';

  const compilerOptions = loadCompilerOptions(path.dirname(resolvedPath));
  const program = ts.createProgram([resolvedPath], compilerOptions);
  const checker = program.getTypeChecker();
  const sourceFile = program.getSourceFile(resolvedPath);

  if (!sourceFile) {
    throw new Error(`Could not load TypeScript file: ${resolvedPath}`);
  }

  const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
  if (!moduleSymbol) {
    throw new Error(`No module exports found in: ${resolvedPath}`);
  }

  const tsExports = checker.getExportsOfModule(moduleSymbol);
  const routerSymbol = tsExports.find((sym) => sym.getName() === exportName);

  if (!routerSymbol) {
    const available = tsExports.map((e) => e.getName()).join(', ');
    throw new Error(
      `No export named '${exportName}' found in: ${resolvedPath}\n` +
        `Available exports: ${available || '(none)'}`,
    );
  }

  // Prefer the value declaration for value exports; fall back to the declared
  // type for `export type AppRouter = …` aliases.
  let routerType: ts.Type;
  if (routerSymbol.valueDeclaration) {
    routerType = checker.getTypeOfSymbolAtLocation(
      routerSymbol,
      routerSymbol.valueDeclaration,
    );
  } else {
    routerType = checker.getDeclaredTypeOfSymbol(routerSymbol);
  }

  const schemaCtx: SchemaCtx = {
    checker,
    visited: new Set(),
    schemas: {},
    typeToRef: new Map(),
  };

  // Try to dynamically import the router to extract schema descriptions
  const runtimeDescriptions = new Map<string, RuntimeDescriptions>();
  const router = await tryImportRouter(resolvedPath, exportName);
  if (router) {
    collectRuntimeDescriptions(router, '', runtimeDescriptions);
  }

  const walkCtx: WalkCtx = {
    procedures: [],
    seen: new Set(),
    schemaCtx,
    runtimeDescriptions,
  };
  walkType({ type: routerType, ctx: walkCtx, currentPath: '' });

  const errorSchema = extractErrorSchema(routerType, checker, schemaCtx);
  return buildOpenAPIDocument(walkCtx.procedures, options, {
    errorSchema,
    schemas: schemaCtx.schemas,
  });
}
