/**
 * @beignet/core/config
 *
 * Environment-first configuration layer using Standard Schema (Zod, Valibot,
 * ArkType, etc.) for Beignet applications and providers.
 */

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

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

/**
 * Runtime environment object shape.
 */
export type RuntimeEnv = Record<string, string | undefined>;

/**
 * Map of environment variable names to Standard Schema validators.
 */
export type EnvSchemaShape = Record<string, StandardSchema>;
type EmptyEnvSchemaShape = Record<keyof never, never>;
type NoInferType<T> = [T][T extends unknown ? 0 : never];

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

/**
 * Infer the parsed output object for an env schema shape.
 */
export type InferEnvShape<Shape extends EnvSchemaShape> = {
  [Key in keyof Shape]: InferOutput<Shape[Key]>;
};

/**
 * Env schema shape constrained to a client prefix.
 */
export type ClientEnvSchemaShape<ClientPrefix extends string> =
  ClientPrefix extends ""
    ? EnvSchemaShape
    : Record<`${ClientPrefix}${string}`, StandardSchema>;

type ValidateClientEnvShape<
  ClientPrefix extends string,
  Client extends EnvSchemaShape,
> = ClientPrefix extends ""
  ? Client
  : {
      [Key in keyof Client]: Key extends `${ClientPrefix}${string}`
        ? Client[Key]
        : never;
    };

/**
 * Raw Standard Schema validation issue produced while loading config.
 */
export type EnvValidationIssue = StandardSchemaV1.Issue;

/**
 * Error thrown when Beignet config validation fails.
 */
export class ConfigValidationError extends Error {
  /**
   * Raw Standard Schema validation issues.
   */
  readonly issues: readonly EnvValidationIssue[];

  constructor(issues: readonly EnvValidationIssue[], message?: string) {
    super(message ?? formatStandardSchemaIssues(issues));
    this.name = "ConfigValidationError";
    this.issues = issues;
  }
}

/**
 * Options for reading raw environment variables.
 */
export interface ReadEnvOptions {
  /**
   * Runtime environment object. Defaults to `process.env`.
   */
  env?: RuntimeEnv;
  /**
   * Optional prefix to filter and strip from matching keys.
   */
  prefix?: string;
  /**
   * Treat empty strings as missing values.
   */
  emptyStringAsUndefined?: boolean;
}

/**
 * Options for creating a single validated env loader.
 */
export interface CreateEnvLoaderOptions<Schema extends StandardSchemaV1> {
  /**
   * Standard Schema for validating the full environment object.
   */
  schema: Schema;

  /**
   * Optional prefix to filter env vars. Matching keys are stripped before
   * validation, so `APP_DATABASE_URL` becomes `DATABASE_URL`.
   */
  prefix?: string;

  /**
   * Runtime environment object. Defaults to `process.env` when available.
   */
  runtimeEnv?: RuntimeEnv;

  /**
   * Treat empty strings as missing values before validation.
   */
  emptyStringAsUndefined?: boolean;

  /**
   * Skip validation and return the raw env object. This is intended for build
   * phases where real secrets are unavailable.
   */
  skipValidation?: boolean;

  /**
   * Called when validation fails. Throw from this hook to customize the error.
   */
  onValidationError?: (issues: readonly EnvValidationIssue[]) => never;
}

/**
 * Validated env loader returned by `createEnvLoader(...)`.
 */
export interface EnvInstance<Out> {
  /**
   * Read and validate the environment.
   */
  load(options?: { env?: RuntimeEnv }): Out;
}

/**
 * Options for `createEnv(...)`.
 */
export interface CreateEnvOptions<
  Server extends EnvSchemaShape,
  ClientPrefix extends string,
  Client extends EnvSchemaShape,
> {
  /**
   * Server-only environment variables. These throw if accessed from a client
   * runtime through the returned env object.
   */
  server?: Server;

  /**
   * Client-safe environment variables. Keys must use `clientPrefix` when a
   * prefix is provided.
   */
  client?: Client & ValidateClientEnvShape<NoInferType<ClientPrefix>, Client>;

  /**
   * Prefix required for client variables, e.g. `NEXT_PUBLIC_`.
   */
  clientPrefix?: ClientPrefix;

  /**
   * Runtime environment object. Defaults to `process.env` when available.
   */
  runtimeEnv?: RuntimeEnv;

  /**
   * Strict runtime environment object. Every declared key must be present on the
   * object, even if the value is `undefined`. This catches framework bundling
   * mistakes where an env var was not explicitly accessed.
   */
  runtimeEnvStrict?: RuntimeEnv;

  /**
   * Treat empty strings as missing values before validation.
   *
   * Defaults to `true` for `createEnv` because it keeps defaults ergonomic in
   * framework starters.
   */
  emptyStringAsUndefined?: boolean;

  /**
   * Skip validation and return raw values. Use sparingly for build phases where
   * deployment secrets are unavailable.
   */
  skipValidation?: boolean;

  /**
   * Override server detection. Defaults to checking for `window` on globalThis.
   */
  isServer?: boolean;

  /**
   * Called when validation fails. Throw from this hook to customize the error.
   */
  onValidationError?: (issues: readonly EnvValidationIssue[]) => never;

  /**
   * Called when a server-only variable is read from a client runtime.
   */
  onInvalidAccess?: (key: string) => never;
}

/**
 * Parsed env object returned by `createEnv(...)`.
 */
export type CreateEnvResult<
  Server extends EnvSchemaShape,
  Client extends EnvSchemaShape,
> = Readonly<InferEnvShape<Server> & InferEnvShape<Client>>;

function defaultRuntimeEnv(): RuntimeEnv {
  return typeof process !== "undefined" && process.env ? process.env : {};
}

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
  return (
    value !== null &&
    (typeof value === "object" || typeof value === "function") &&
    "then" in value &&
    typeof (value as { then?: unknown }).then === "function"
  );
}

function normalizeEnvValue(
  value: string | undefined,
  emptyStringAsUndefined: boolean,
): string | undefined {
  if (emptyStringAsUndefined && value === "") return undefined;
  return value;
}

function issuePath(issue: StandardSchemaV1.Issue): string | undefined {
  if (!issue.path?.length) return undefined;

  return issue.path
    .map((segment) => {
      if (segment && typeof segment === "object" && "key" in segment) {
        return String(segment.key);
      }

      if (
        typeof segment === "string" ||
        typeof segment === "number" ||
        typeof segment === "symbol"
      ) {
        return String(segment);
      }

      return undefined;
    })
    .filter((segment): segment is string => Boolean(segment))
    .join(".");
}

function prependIssuePath(
  key: string,
  issue: StandardSchemaV1.Issue,
): StandardSchemaV1.Issue {
  return {
    ...issue,
    path: [{ key }, ...(issue.path ?? [])],
  };
}

/**
 * Format Standard Schema issues into a single readable message.
 */
export function formatStandardSchemaIssues(
  issues: readonly StandardSchemaV1.Issue[],
): string {
  return issues
    .map((issue) => {
      const path = issuePath(issue);
      return path ? `${path}: ${issue.message}` : issue.message;
    })
    .join("; ");
}

/**
 * Read raw environment variables, optionally filtering by prefix.
 *
 * When a prefix is provided, matching keys are stripped before being returned.
 */
export function readEnv({
  env = defaultRuntimeEnv(),
  prefix,
  emptyStringAsUndefined = false,
}: ReadEnvOptions = {}): Record<string, string | undefined> {
  const raw: Record<string, string | undefined> = {};

  for (const [key, value] of Object.entries(env)) {
    if (value == null) continue;

    if (prefix && prefix.length > 0) {
      if (!key.startsWith(prefix)) continue;
      raw[key.slice(prefix.length)] = normalizeEnvValue(
        value,
        emptyStringAsUndefined,
      );
      continue;
    }

    raw[key] = normalizeEnvValue(value, emptyStringAsUndefined);
  }

  return raw;
}

function validationResultValue<Schema extends StandardSchemaV1>(
  result: StandardSchemaV1.Result<InferOutput<Schema>>,
): InferOutput<Schema> {
  if (result.issues?.length) {
    throw new ConfigValidationError(result.issues);
  }

  if ("value" in result) {
    return result.value as InferOutput<Schema>;
  }

  throw new Error("Invalid Standard Schema result: missing value");
}

/**
 * Validate input with a synchronous Standard Schema.
 *
 * Throws when the schema returns a promise because env loading is synchronous.
 */
export function parseStandardSchemaSync<Schema extends StandardSchemaV1>(
  schema: Schema,
  input: unknown,
): InferOutput<Schema> {
  const validate = schema?.["~standard"]?.validate;

  if (typeof validate !== "function") {
    throw new Error("Invalid Standard Schema: missing ~standard.validate()");
  }

  const result = validate(input);

  if (isPromiseLike(result)) {
    throw new Error(
      "[Beignet env] Schema uses async validation, which is not supported by load().",
    );
  }

  return validationResultValue<Schema>(result);
}

/**
 * Validate input with a Standard Schema that may be synchronous or async.
 */
export async function parseStandardSchemaAsync<Schema extends StandardSchemaV1>(
  schema: Schema,
  input: unknown,
): Promise<InferOutput<Schema>> {
  const validate = schema?.["~standard"]?.validate;

  if (typeof validate !== "function") {
    throw new Error("Invalid Standard Schema: missing ~standard.validate()");
  }

  return validationResultValue<Schema>(await validate(input));
}

function wrapEnvError(err: unknown, prefix?: string): Error {
  if (err instanceof ConfigValidationError) {
    return new Error(
      `[Beignet env] Invalid environment${
        prefix ? ` for prefix "${prefix}"` : ""
      }: ${err.message}`,
    );
  }

  if (err instanceof Error) {
    return new Error(
      `[Beignet env] Invalid environment${
        prefix ? ` for prefix "${prefix}"` : ""
      }: ${err.message}`,
    );
  }

  return new Error(
    `[Beignet env] Invalid environment${
      prefix ? ` for prefix "${prefix}"` : ""
    }: ${String(err)}`,
  );
}

/**
 * Create a reusable validated env loader.
 *
 * This is useful for provider config and app-level config objects that are
 * loaded from a prefixed subset of the environment.
 */
export function createEnvLoader<Schema extends StandardSchemaV1>(
  options: CreateEnvLoaderOptions<Schema>,
): EnvInstance<InferOutput<Schema>> {
  const {
    schema,
    prefix,
    runtimeEnv,
    emptyStringAsUndefined = false,
    skipValidation = false,
    onValidationError,
  } = options;

  function load({
    env = runtimeEnv ?? defaultRuntimeEnv(),
  } = {}): InferOutput<Schema> {
    const raw = readEnv({ env, prefix, emptyStringAsUndefined });

    if (skipValidation) {
      return raw as InferOutput<Schema>;
    }

    try {
      return parseStandardSchemaSync(schema, raw);
    } catch (err) {
      if (err instanceof ConfigValidationError) {
        onValidationError?.(err.issues);
      }

      throw wrapEnvError(err, prefix);
    }
  }

  return { load };
}

function validateEnvShape<Shape extends EnvSchemaShape>(
  shape: Shape,
  runtimeEnv: RuntimeEnv,
  emptyStringAsUndefined: boolean,
): InferEnvShape<Shape> {
  const output: Record<string, unknown> = {};
  const issues: StandardSchemaV1.Issue[] = [];

  for (const [key, schema] of Object.entries(shape)) {
    const value = normalizeEnvValue(runtimeEnv[key], emptyStringAsUndefined);

    try {
      output[key] = parseStandardSchemaSync(schema, value);
    } catch (error) {
      if (error instanceof ConfigValidationError) {
        issues.push(
          ...error.issues.map((issue) => prependIssuePath(key, issue)),
        );
        continue;
      }

      throw error;
    }
  }

  if (issues.length) {
    throw new ConfigValidationError(issues);
  }

  return output as InferEnvShape<Shape>;
}

function validateClientPrefix(
  client: EnvSchemaShape,
  clientPrefix: string | undefined,
) {
  if (!clientPrefix) return;

  for (const key of Object.keys(client)) {
    if (!key.startsWith(clientPrefix)) {
      throw new Error(
        `[Beignet env] Client environment variable "${key}" must start with "${clientPrefix}".`,
      );
    }
  }
}

function validateRuntimeEnvStrict(
  runtimeEnv: RuntimeEnv,
  keys: readonly string[],
) {
  const missing = keys.filter((key) => !Object.hasOwn(runtimeEnv, key));
  if (missing.length === 0) return;

  throw new Error(
    `[Beignet env] runtimeEnvStrict is missing declared keys: ${missing.join(
      ", ",
    )}`,
  );
}

/**
 * Create a server/client split env object.
 *
 * Server variables are available only on the server. Client variables must use
 * `clientPrefix` when provided. Validation is synchronous, empty strings are
 * treated as undefined by default, and client access to server-only keys throws
 * through the returned proxy.
 */
export function createEnv<
  const ClientPrefix extends string = "",
  Server extends EnvSchemaShape = EmptyEnvSchemaShape,
  Client extends EnvSchemaShape = EmptyEnvSchemaShape,
>(
  options: CreateEnvOptions<Server, ClientPrefix, Client>,
): CreateEnvResult<Server, Client> {
  const server = (options.server ?? {}) as Server;
  const client = (options.client ?? {}) as Client;
  const runtimeEnv =
    options.runtimeEnvStrict ?? options.runtimeEnv ?? defaultRuntimeEnv();
  const emptyStringAsUndefined = options.emptyStringAsUndefined ?? true;
  const isServer = options.isServer ?? !("window" in globalThis);

  if (options.runtimeEnv && options.runtimeEnvStrict) {
    throw new Error(
      "[Beignet env] Specify runtimeEnv or runtimeEnvStrict, not both.",
    );
  }

  validateClientPrefix(client, options.clientPrefix);

  const declaredKeys = [...Object.keys(server), ...Object.keys(client)];
  const validationShape = isServer ? { ...server, ...client } : client;
  const validationKeys = isServer ? declaredKeys : Object.keys(client);

  if (options.runtimeEnvStrict) {
    validateRuntimeEnvStrict(runtimeEnv, validationKeys);
  }

  let parsed: Record<string, unknown>;

  if (options.skipValidation) {
    parsed = Object.fromEntries(
      validationKeys.map((key) => [
        key,
        normalizeEnvValue(runtimeEnv[key], emptyStringAsUndefined),
      ]),
    );
  } else {
    try {
      parsed = validateEnvShape(
        validationShape,
        runtimeEnv,
        emptyStringAsUndefined,
      );
    } catch (error) {
      if (error instanceof ConfigValidationError) {
        options.onValidationError?.(error.issues);
      }

      throw wrapEnvError(error);
    }
  }

  const serverKeys = new Set(Object.keys(server));
  const clientKeys = new Set(Object.keys(client));

  return new Proxy(parsed, {
    get(target, property, receiver) {
      if (
        typeof property === "string" &&
        !isServer &&
        serverKeys.has(property) &&
        !clientKeys.has(property)
      ) {
        options.onInvalidAccess?.(property);
        throw new Error(
          `[Beignet env] Attempted to access server-only environment variable "${property}" from a client runtime.`,
        );
      }

      return Reflect.get(target, property, receiver);
    },
  }) as CreateEnvResult<Server, Client>;
}
