/* eslint-disable @typescript-eslint/no-unused-vars */
/**
 * Utilities for defining the schema of your Convex project.
 *
 * ## Usage
 *
 * Schemas should be placed in a `schema.ts` file in your `convex/` directory.
 *
 * Schema definitions should be built using {@link defineSchema},
 * {@link defineTable}, and {@link s}. Make sure to export the schema as the
 * default export.
 *
 * ```ts
 * import { defineSchema, defineTable, s } from "convex/schema";
 *
 *  export default defineSchema({
 *    messages: defineTable({
 *      body: s.string(),
 *      user: s.id("users"),
 *    }),
 *    users: defineTable({
 *      name: s.string(),
 *    }),
 *  });
 * ```
 *
 * To learn more about schemas, see [Defining a Schema](https://docs.convex.dev/using/schemas).
 * @module
 */
import { GenericId } from "@convex-dev/common";
import { GenericDocument, GenericTableIndexes } from "../server/data_model.js";
import {
  IdField,
  SystemFields,
  SystemIndexes,
} from "../server/system_fields.js";
import { Expand } from "../type_utils.js";

/**
 * A Convex type defined in a schema.
 *
 * These should be constructed using {@link s}.
 *
 * This class encapsulates:
 * - The TypeScript type of this Convex type.
 * - The TypeScript type for the set of index field paths that can be used to
 * build indexes on this type.
 * - The table names referenced in `s.id` usages in this type.
 * @public
 */
export class SchemaType<TypeScriptType, FieldPaths extends string> {
  readonly type!: TypeScriptType;
  readonly fieldPaths!: FieldPaths;

  // Property for a bit of nominal type safety.
  private _isSchemaType: undefined;

  readonly referencedTableNames: Set<string>;
  constructor(referencedTableNames: Set<string> = new Set()) {
    this.referencedTableNames = referencedTableNames;
  }
}

/**
 * The schema builder.
 *
 * This builder allows you to define the types of documents stored in Convex.
 * @public
 */
export const s = {
  id<TableName extends string>(
    tableName: TableName
  ): SchemaType<GenericId<TableName>, never> {
    return new SchemaType(new Set([tableName]));
  },
  null(): SchemaType<null, never> {
    return new SchemaType();
  },
  number(): SchemaType<number, never> {
    return new SchemaType();
  },
  bigint(): SchemaType<bigint, never> {
    return new SchemaType();
  },
  boolean(): SchemaType<boolean, never> {
    return new SchemaType();
  },
  string(): SchemaType<string, never> {
    return new SchemaType();
  },
  bytes(): SchemaType<ArrayBuffer, never> {
    return new SchemaType();
  },
  literal<T extends string | number | bigint | boolean>(
    literal: T
  ): SchemaType<T, never> {
    return new SchemaType();
  },
  array<T>(values: SchemaType<T, any>): SchemaType<T[], never> {
    return new SchemaType(values.referencedTableNames);
  },
  set<T>(values: SchemaType<T, any>): SchemaType<Set<T>, never> {
    return new SchemaType(values.referencedTableNames);
  },
  map<K, V>(
    keys: SchemaType<K, any>,
    values: SchemaType<V, any>
  ): SchemaType<Map<K, V>, never> {
    return new SchemaType(
      new Set([...keys.referencedTableNames, ...values.referencedTableNames])
    );
  },
  object<T extends Record<string, SchemaType<any, any>>>(
    schema: T
  ): ObjectSchemaType<T> {
    const referencedTableNames = new Set<string>();
    for (const schemaType of Object.values(schema)) {
      for (const tableName of schemaType.referencedTableNames) {
        referencedTableNames.add(tableName);
      }
    }
    return new SchemaType(referencedTableNames);
  },
  union<
    T extends [
      SchemaType<any, any>,
      SchemaType<any, any>,
      ...SchemaType<any, any>[]
    ]
  >(...schemaTypes: T): SchemaType<T[number]["type"], T[number]["fieldPaths"]> {
    const referencedTableNames = new Set<string>();
    for (const schemaType of schemaTypes) {
      for (const tableName of schemaType.referencedTableNames) {
        referencedTableNames.add(tableName);
      }
    }
    return new SchemaType(referencedTableNames);
  },
};

/**
 * Calculate the {@link SchemaType} for an object.
 *
 * This is used within the SchemaBuilder {@link s}.
 * @public
 */
type ObjectSchemaType<
  SchemaValueType extends Record<string, SchemaType<any, any>>
> = SchemaType<
  // Compute the TypeScript type for this object. Just map the keys to the values
  // inside the SchemaTypes
  {
    [Property in keyof SchemaValueType]: SchemaValueType[Property] extends SchemaType<
      infer InnerType,
      any
    >
      ? InnerType
      : never;
  },
  // Compute the field paths for this object. For every property in the object
  // Add on a field path for that property and extend all the field paths in the
  // value.
  {
    [Property in keyof SchemaValueType]: SchemaValueType[Property] extends SchemaType<
      any,
      infer FieldPaths
    >
      ? JoinFieldPaths<Property & string, FieldPaths> | Property
      : never;
  }[keyof SchemaValueType] &
    string
>;

/**
 * Join together two index field paths.
 *
 * This is used within the SchemaBuilder {@link s}.
 * @public
 */
type JoinFieldPaths<
  Start extends string,
  End extends string
> = `${Start}.${End}`;

/**
 * Extract all of the index field paths within a {@link SchemaType}.
 *
 * This is used within {@link defineTable}.
 * @public
 */
type ExtractFieldPaths<T extends SchemaType<any, any>> = T extends SchemaType<
  any,
  infer FieldPaths
>
  ? // Add in the system fields available in index definitions.
    // This should be everything except for `_id` because thats added to indexes
    // automatically.
    FieldPaths | keyof SystemFields
  : never;

/**
 * Extract the {@link GenericDocument} within a {@link SchemaType} and
 * add on the system fields.
 *
 * This is used within {@link defineTable}.
 * @public
 */
type ExtractDocument<T extends SchemaType<any, any>> = T extends SchemaType<
  infer Value,
  any
>
  ? Value extends GenericDocument
    ? // Add the system fields to `Value` (except `_id` because it depends on
      //the table name) and trick TypeScript into expanding them.
      Expand<SystemFields & Value>
    : never
  : never;
/**
 * The definition of a table within a schema.
 *
 * This should be produced by using {@link defineTable}.
 * @public
 */
export class TableDefinition<
  Document extends GenericDocument = GenericDocument,
  FieldPaths extends string = string,
  // eslint-disable-next-line @typescript-eslint/ban-types
  TableIndexes extends GenericTableIndexes = {}
> {
  // A map of index name to index fields.
  private indexes: { indexDescriptor: string; fields: string[] }[];
  // The type of documents stored in this table.
  private documentType: SchemaType<any, any>;

  /**
   * @internal
   */
  constructor(documentType: SchemaType<any, any>) {
    this.indexes = [];
    this.documentType = documentType;
  }

  /**
   * Define an index on this table.
   *
   * To learn about indexes, see [Defining Indexes](https://docs.convex.dev/using/indexes).
   *
   * @param name - The name of the index.
   * @param fields - The fields to index, in order. Must specify at least one
   * field.
   * @returns A {@link TableDefinition} with this index included.
   */
  index<
    IndexName extends string,
    FirstFieldPath extends FieldPaths,
    RestFieldPaths extends FieldPaths[]
  >(
    name: IndexName,
    fields: [FirstFieldPath, ...RestFieldPaths]
  ): TableDefinition<
    Document,
    FieldPaths,
    // Update `TableIndexes` include the new index and use `Expand` to make the
    // types look pretty in editors.
    Expand<
      TableIndexes & Record<IndexName, [FirstFieldPath, ...RestFieldPaths]>
    >
  > {
    this.indexes.push({ indexDescriptor: name, fields });
    return this;
  }

  /**
   * Export the contents of this definition.
   *
   * This is called internally by the Convex framework.
   * @internal
   */
  export() {
    return {
      indexes: this.indexes,
      referencedTableNames: this.documentType.referencedTableNames,
    };
  }
}

/**
 * Define a table in a schema.
 *
 * You can either specify the schema of your documents as an object like
 * ```ts
 * defineTable({
 *   field: s.string()
 * });
 * ```
 *
 * or as a schema type like
 * ```ts
 * defineTable(s.object({
 *   field: s.string()
 * }));
 * ```
 *
 * @param documentSchema - The type of documents stored in this table.
 * @returns A {@link TableDefinition} for the table.
 *
 * @public
 */
export function defineTable<
  DocumentSchema extends SchemaType<Record<string, any>, any>
>(
  documentSchema: DocumentSchema
): TableDefinition<
  ExtractDocument<DocumentSchema>,
  ExtractFieldPaths<DocumentSchema>
>;
/**
 * Define a table in a schema.
 *
 * You can either specify the schema of your documents as an object like
 * ```ts
 * defineTable({
 *   field: s.string()
 * });
 * ```
 *
 * or as a schema type like
 * ```ts
 * defineTable(s.object({
 *   field: s.string()
 * }));
 * ```
 *
 * @param documentSchema - The type of documents stored in this table.
 * @returns A {@link TableDefinition} for the table.
 *
 * @public
 */
export function defineTable<
  DocumentSchema extends Record<string, SchemaType<any, any>>
>(
  documentSchema: DocumentSchema
): TableDefinition<
  ExtractDocument<ObjectSchemaType<DocumentSchema>>,
  ExtractFieldPaths<ObjectSchemaType<DocumentSchema>>
>;
export function defineTable<
  DocumentSchema extends
    | SchemaType<Record<string, any>, any>
    | Record<string, SchemaType<any, any>>
>(documentSchema: DocumentSchema): TableDefinition<any, any> {
  if (documentSchema instanceof SchemaType) {
    return new TableDefinition(documentSchema);
  } else {
    return new TableDefinition(s.object(documentSchema));
  }
}

/**
 * A type describing the schema of a Convex project.
 *
 * This should be constructed using {@link defineSchema}, {@link defineTable},
 * and {@link s}.
 * @public
 */
export type GenericSchema = Record<string, TableDefinition>;

/**
 *
 * The definition of a Convex project schema.
 *
 * This should be produced by using {@link defineSchema}.
 * @public
 */
export class SchemaDefinition<Schema extends GenericSchema> {
  private tables: GenericSchema;

  /**
   * @internal
   */
  constructor(tables: Schema) {
    this.tables = tables;
  }

  /**
   * Export the contents of this definition.
   *
   * This is called internally by the Convex framework.
   * @internal
   */
  export(): string {
    const tableNames = new Set(Object.keys(this.tables));

    return JSON.stringify({
      tables: Object.entries(this.tables).map(([tableName, definition]) => {
        const { indexes, referencedTableNames } = definition.export();

        // Make sure all the referenced table names are actually defined.
        for (const referencedTableName of referencedTableNames) {
          if (!tableNames.has(referencedTableName)) {
            throw new Error(
              `SchemaValidationError: Table ${tableName} has a \`s.id\` ` +
                `expression that references table ${referencedTableName} which isn't defined in the schema.`
            );
          }
        }

        return {
          tableName,
          indexes,
        };
      }),
    });
  }
}

/**
 * Define the schema of this Convex project.
 *
 * This should be exported from a `schema.ts` file in your `convex/` directory
 * like:
 *
 * ```ts
 * export default defineSchema({
 *   ...
 * });
 * ```
 *
 * @param schema - A map from table name to {@link TableDefinition} for all of
 * the tables in this project.
 * @returns The schema.
 *
 * @public
 */
export function defineSchema<Schema extends GenericSchema>(
  schema: Schema
): SchemaDefinition<Schema> {
  return new SchemaDefinition(schema);
}

/**
 * Internal type used in Convex code generation!
 *
 * Convert a {@link SchemaDefinition} into a {@link server.GenericDataModel}.
 *
 * @public
 */
export type DataModelFromSchemaDefinition<
  SchemaDef extends SchemaDefinition<any>
> = SchemaDef extends SchemaDefinition<infer Schema>
  ? {
      [TableName in keyof Schema &
        string]: Schema[TableName] extends TableDefinition<
        infer Document,
        infer FieldPaths,
        infer TableIndexes
      >
        ? {
            // We've already added all of the system fields except for `_id`.
            // Add that here.
            document: Expand<IdField<TableName> & Document>;
            fieldPaths: keyof IdField<TableName> | FieldPaths;
            indexes: Expand<TableIndexes & SystemIndexes>;
          }
        : never;
    }
  : never;

/**
 * Internal type used in Convex code generation!
 *
 * Convert a {@link SchemaDefinition} into an object type that maps table names
 * to their document types.
 *
 * This is similar to {@link server.GenericDataModel} but it doesn't contain
 * index information. This is nice for making some types appear simpler in
 * VSCode.
 *
 * @public
 */
export type DocumentMapFromSchemaDefinition<
  SchemaDef extends SchemaDefinition<any>
> = SchemaDef extends SchemaDefinition<infer Schema>
  ? {
      [TableName in keyof Schema &
        string]: Schema[TableName] extends TableDefinition<
        infer Document,
        any,
        any
      >
        ? Expand<IdField<TableName> & Document>
        : never;
    }
  : never;
