import type { StandardSchemaV1 } from "@standard-schema/spec";

/**
 * Any Standard Schema compatible validator.
 */
export type StandardSchema = StandardSchemaV1<unknown, unknown>;

/**
 * Infer the parsed output type from a Standard Schema.
 */
export type InferOutput<T extends StandardSchemaV1> =
  StandardSchemaV1.InferOutput<T>;

/**
 * Validate data using a Standard Schema validator synchronously if possible
 */
function validateSchemaSync<T>(
  schema: StandardSchemaV1<unknown, T>,
  data: unknown,
): T | Promise<T> {
  const result = schema["~standard"].validate(data);

  if (result instanceof Promise) {
    return result.then((resolved) => {
      if (resolved.issues?.length) {
        const message = resolved.issues
          .map((issue) => {
            const path =
              issue.path !== undefined
                ? Array.isArray(issue.path)
                  ? issue.path.join(".")
                  : String(issue.path)
                : "";
            return path ? `${path}: ${issue.message}` : issue.message;
          })
          .join("; ");
        throw new Error(`Validation failed: ${message}`);
      }
      if ("value" in resolved) {
        return resolved.value;
      }
      throw new Error("Invalid Standard Schema result: missing value");
    });
  }

  if (result.issues?.length) {
    const message = result.issues
      .map((issue) => {
        const path =
          issue.path !== undefined
            ? Array.isArray(issue.path)
              ? issue.path.join(".")
              : String(issue.path)
            : "";
        return path ? `${path}: ${issue.message}` : issue.message;
      })
      .join("; ");
    throw new Error(`Validation failed: ${message}`);
  }
  if ("value" in result) {
    return result.value;
  }
  throw new Error("Invalid Standard Schema result: missing value");
}

// biome-ignore lint/suspicious/noExplicitAny: Base methods type for flexibility
type AnyMethods = Record<string, any>;

/**
 * Base entity instance passed to the `.methods(...)` builder: validated props
 * plus the `with` and `toJSON` helpers, before domain methods attach.
 *
 * `with(...)` is typed as returning the base instance here because the final
 * methods object is still being defined while the builder runs. At runtime
 * the returned instance carries the entity's methods, and `EntityInstance`
 * maps base-instance returns back to the full instance type so consumers keep
 * typed method chaining.
 */
export type EntityBaseInstance<Schema extends StandardSchema> =
  InferOutput<Schema> & {
    /**
     * Create a new validated entity instance with patched properties.
     */
    with(
      patch: Partial<InferOutput<Schema>>,
    ): EntityBaseInstance<Schema> | Promise<EntityBaseInstance<Schema>>;
    /**
     * Convert the entity to a plain JSON object for persistence.
     */
    toJSON(): InferOutput<Schema>;
  };

/**
 * Replace base-instance returns with the full entity instance type, unwrapping
 * promises, so methods that return `self` or `self.with(...)` chain with the
 * entity's methods attached.
 */
type WithEntityInstance<R, Instance, Schema extends StandardSchema> =
  R extends Promise<infer P>
    ? Promise<WithEntityInstance<P, Instance, Schema>>
    : R extends EntityBaseInstance<Schema>
      ? Instance
      : R;

/**
 * Entity instance with validated props, attached methods, and immutable update
 * helpers. Method returns that are typed as the base instance (`self` or
 * `self.with(...)`) are mapped to the full instance type so chaining keeps
 * the entity's methods.
 */
export type EntityInstance<
  Name extends string,
  Schema extends StandardSchema,
  Methods extends AnyMethods,
> = InferOutput<Schema> & {
  /**
   * Create a new validated entity instance with patched properties.
   */
  with(
    patch: Partial<InferOutput<Schema>>,
  ):
    | EntityInstance<Name, Schema, Methods>
    | Promise<EntityInstance<Name, Schema, Methods>>;
  /**
   * Convert the entity to a plain JSON object for persistence.
   */
  toJSON(): InferOutput<Schema>;
} & {
  [K in keyof Methods]: Methods[K] extends (...args: infer A) => infer R
    ? (
        ...args: A
      ) => WithEntityInstance<R, EntityInstance<Name, Schema, Methods>, Schema>
    : Methods[K];
};

/**
 * Entity definition returned by `defineEntity(...).build()`.
 */
export interface EntityDef<
  Name extends string,
  Schema extends StandardSchema,
  Methods extends AnyMethods,
> {
  /** Name used for debugging and introspection. */
  name: Name;
  /** Standard Schema used to validate entity props. */
  schema: Schema;
  /**
   * Create a new frozen entity instance from props.
   *
   * Returns a promise when the underlying Standard Schema validates async.
   */
  create(
    props: InferOutput<Schema>,
  ):
    | EntityInstance<Name, Schema, Methods>
    | Promise<EntityInstance<Name, Schema, Methods>>;
  /**
   * Reconstruct an entity instance from JSON, usually from persistence.
   */
  fromJSON(
    json: InferOutput<Schema>,
  ):
    | EntityInstance<Name, Schema, Methods>
    | Promise<EntityInstance<Name, Schema, Methods>>;
  /** Type-only alias for the entity instance type. Undefined at runtime. */
  Type: EntityInstance<Name, Schema, Methods>;
}

/**
 * Builder class for creating Entities/Aggregates.
 */
class EntityBuilder<
  Name extends string,
  Schema extends StandardSchema,
  Methods extends AnyMethods,
> {
  constructor(
    private readonly cfg: {
      name: Name;
      schema?: Schema;
      methods?: (self: EntityBaseInstance<Schema>) => Methods;
    },
  ) {}

  /**
   * Define the schema for this entity using any Standard Schema compatible validator.
   */
  props<S extends StandardSchema>(schema: S): EntityBuilder<Name, S, Methods> {
    return new EntityBuilder<Name, S, Methods>({
      name: this.cfg.name,
      schema,
      // Methods declared before props were typed against the previous schema;
      // canonical builder order is props-then-methods, so this widening only
      // affects the discouraged reverse order.
      methods: this.cfg.methods as unknown as
        | ((self: EntityBaseInstance<S>) => Methods)
        | undefined,
    });
  }

  /**
   * Define methods to attach to entity instances.
   * The method builder receives the typed base instance: validated props plus
   * `with` and `toJSON`.
   */
  methods<M extends AnyMethods>(
    build: (self: EntityBaseInstance<Schema>) => M,
  ): EntityBuilder<Name, Schema, M> {
    return new EntityBuilder<Name, Schema, M>({
      name: this.cfg.name,
      schema: this.cfg.schema,
      methods: build,
    });
  }

  /**
   * Finalize and build the entity definition.
   */
  build(): EntityDef<Name, Schema, Methods> {
    if (!this.cfg.schema) {
      throw new Error(`Entity "${this.cfg.name}" is missing props schema`);
    }

    const schema = this.cfg.schema;
    const methodsBuilder = this.cfg.methods;

    const createInstance = (
      raw: InferOutput<Schema>,
    ):
      | EntityInstance<Name, Schema, Methods>
      | Promise<EntityInstance<Name, Schema, Methods>> => {
      const validationResult = validateSchemaSync(schema, raw);

      const buildInstance = (
        parsed: InferOutput<Schema>,
      ): EntityInstance<Name, Schema, Methods> => {
        // Cast parsed to a plain object for spreading
        const parsedObj = parsed as Record<string, unknown>;

        const base = {
          ...parsedObj,
          // Immutable update - creates a new validated instance
          // Note: Re-validates the merged props for data integrity
          with(patch: Partial<InferOutput<Schema>>) {
            const patchObj = patch as Record<string, unknown>;
            return createInstance({
              ...parsedObj,
              ...patchObj,
            } as InferOutput<Schema>);
          },
          toJSON() {
            return parsed;
          },
        } as EntityBaseInstance<Schema>;

        const methods = methodsBuilder?.(base) ?? {};
        const instance = Object.freeze({
          ...base,
          ...methods,
        }) as EntityInstance<Name, Schema, Methods>;

        return instance;
      };

      if (validationResult instanceof Promise) {
        return validationResult.then(buildInstance);
      }
      return buildInstance(validationResult);
    };

    const def: EntityDef<Name, Schema, Methods> = {
      name: this.cfg.name,
      schema,
      create: createInstance,
      fromJSON: createInstance,
      // Type is undefined at runtime - used only for TypeScript type inference via `typeof Entity.Type`
      Type: undefined as unknown as EntityInstance<Name, Schema, Methods>,
    };

    return def;
  }
}

/**
 * Create a new entity builder.
 *
 * Entities validate props, attach domain methods, and return frozen immutable
 * instances. `with(...)` revalidates the merged props. `.Type` is type-only and
 * should be used with `typeof Entity.Type`.
 *
 * @example
 * ```ts
 * const Todo = defineEntity("Todo")
 *   .props(z.object({
 *     id: z.string(),
 *     title: z.string(),
 *     assigneeIds: z.array(z.string()).default([]),
 *   }))
 *   .methods((self) => ({
 *     addAssignee(id: string) {
 *       if (self.assigneeIds.includes(id)) return self;
 *       return self.with({ assigneeIds: [...self.assigneeIds, id] });
 *     },
 *   }))
 *   .build();
 *
 * type Todo = typeof Todo.Type;
 * ```
 */
export function defineEntity<Name extends string>(name: Name) {
  return new EntityBuilder<Name, StandardSchema, Record<string, never>>({
    name,
  });
}
