/**
 * @wrnexus/validation — one schema, validated on the server (API) and the browser
 * (forms). A schema is a fluent builder; `.parse()` runs server-side and returns
 * coerced values + field errors, while `.describe()` emits a JSON descriptor the
 * eval-free client validator interprets. Define schemas once in `app/schemas/`.
 */

// --- Descriptor (the JSON bridge between server and client) ----------------

export type RuleDescriptor =
  | { kind: "min"; n: number; message?: string }
  | { kind: "max"; n: number; message?: string }
  | { kind: "length"; n: number; message?: string }
  | { kind: "email"; message?: string }
  | { kind: "url"; message?: string }
  | { kind: "uuid"; message?: string }
  | { kind: "date"; message?: string }
  | { kind: "oneOf"; values: (string | number)[]; message?: string }
  | { kind: "pattern"; source: string; flags?: string; message?: string }
  | { kind: "integer"; message?: string };

export interface FieldDescriptor {
  type: "string" | "number" | "boolean" | "unknown";
  optional?: boolean;
  /** Message used when a required field is empty. Defaults to "Required". */
  requiredMessage?: string;
  /** Message used when coercion to the declared type fails. */
  typeMessage?: string;
  label?: string;
  /** Trim string input before validating. */
  trim?: boolean;
  rules: RuleDescriptor[];
}

export interface SchemaDescriptor {
  type: "object";
  fields: Record<string, FieldDescriptor>;
}

export interface ParseResult<T = Record<string, unknown>> {
  ok: boolean;
  /** Coerced values (present whether or not validation passed). */
  value: T;
  /** Field name → message, only for fields that failed. */
  errors: Record<string, string>;
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

/**
 * Apply one rule to an already-coerced value. Shared by the server; the client
 * runtime (runtime.ts) mirrors this exactly. Returns an error message or null.
 */
export function applyRule(type: string, rule: RuleDescriptor, value: unknown): string | null {
  switch (rule.kind) {
    case "min":
      if (type === "string")
        return String(value).length < rule.n
          ? (rule.message ?? `Must be at least ${rule.n} characters`)
          : null;
      return (value as number) < rule.n ? (rule.message ?? `Must be at least ${rule.n}`) : null;
    case "max":
      if (type === "string")
        return String(value).length > rule.n
          ? (rule.message ?? `Must be at most ${rule.n} characters`)
          : null;
      return (value as number) > rule.n ? (rule.message ?? `Must be at most ${rule.n}`) : null;
    case "length":
      return String(value).length !== rule.n
        ? (rule.message ?? `Must be exactly ${rule.n} characters`)
        : null;
    case "email":
      return EMAIL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid email");
    case "url":
      return URL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid URL");
    case "uuid":
      return UUID_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid UUID");
    case "date":
      return Number.isNaN(Date.parse(String(value)))
        ? (rule.message ?? "Must be a valid date")
        : null;
    case "oneOf":
      return rule.values.includes(value as string | number)
        ? null
        : (rule.message ?? `Must be one of: ${rule.values.join(", ")}`);
    case "pattern":
      try {
        return new RegExp(rule.source, rule.flags ?? "").test(String(value))
          ? null
          : (rule.message ?? "Invalid format");
      } catch {
        return null;
      }
    case "integer":
      return Number.isInteger(value) ? null : (rule.message ?? "Must be a whole number");
    default:
      return null;
  }
}

/** Coerce + validate one field against its descriptor. */
export function checkField(
  desc: FieldDescriptor,
  raw: unknown,
): { value: unknown; error: string | null } {
  if (desc.type === "unknown") {
    const empty = raw === undefined || raw === null || raw === "";
    return {
      value: raw,
      error: empty && !desc.optional ? desc.requiredMessage || "Required" : null,
    };
  }

  if (desc.type === "boolean") {
    const value = raw === true || raw === "true" || raw === "on";
    if (!desc.optional && !value) {
      return { value, error: desc.requiredMessage || "Required" };
    }
    return { value, error: null };
  }

  const pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
  const empty = pre === undefined || pre === null || pre === "";
  if (empty) {
    return {
      value: undefined,
      error: desc.optional ? null : desc.requiredMessage || "Required",
    };
  }

  let value: unknown;
  if (desc.type === "number") {
    value = Number(pre);
    if (Number.isNaN(value)) return { value, error: desc.typeMessage ?? "Must be a number" };
  } else {
    value = String(pre);
  }
  for (const rule of desc.rules) {
    const error = applyRule(desc.type, rule, value);
    if (error) return { value, error };
  }
  return { value, error: null };
}

// --- Fluent builder --------------------------------------------------------

/** A server-only refinement (a predicate that can't be serialized to the client). */
type Refinement = { fn: (value: unknown) => boolean | string; message?: string };

export abstract class FieldSchema {
  abstract readonly type: "string" | "number" | "boolean" | "unknown";
  protected _optional = false;
  protected _requiredMessage?: string;
  protected _label?: string;
  protected _default?: unknown;
  protected rules: RuleDescriptor[] = [];
  protected refinements: Refinement[] = [];

  optional(): this {
    this._optional = true;
    return this;
  }
  /** Require a non-empty value and optionally replace the default message. */
  required(message = "Required"): this {
    this._optional = false;
    this._requiredMessage = message;
    return this;
  }
  label(label: string): this {
    this._label = label;
    return this;
  }
  /** Value used when the field is absent (implies optional). */
  default(value: unknown): this {
    this._default = value;
    this._optional = true;
    return this;
  }
  min(n: number, message?: string): this {
    this.rules.push({ kind: "min", n, message });
    return this;
  }
  max(n: number, message?: string): this {
    this.rules.push({ kind: "max", n, message });
    return this;
  }
  /**
   * Custom SERVER-side validation. `fn` returns true (ok), false (use `message`),
   * or a string (that error). Not mirrored to the client validator.
   */
  refine(fn: (value: unknown) => boolean | string, message?: string): this {
    this.refinements.push({ fn, message });
    return this;
  }
  getDefault(): unknown {
    return this._default;
  }
  runRefinements(value: unknown): string | null {
    for (const r of this.refinements) {
      const result = r.fn(value);
      if (result === false) return r.message ?? "Invalid value";
      if (typeof result === "string") return result;
    }
    return null;
  }
  describe(): FieldDescriptor {
    return {
      type: this.type,
      optional: this._optional || undefined,
      requiredMessage: this._requiredMessage,
      label: this._label,
      rules: this.rules,
    };
  }
}

export class StringSchema<TValue extends string = string> extends FieldSchema {
  /** Type-only marker used to preserve literal unions through schema inference. */
  declare readonly __value: TValue;
  readonly type = "string" as const;
  private _trim = false;
  email(message?: string): this {
    this.rules.push({ kind: "email", message });
    return this;
  }
  url(message?: string): this {
    this.rules.push({ kind: "url", message });
    return this;
  }
  uuid(message?: string): this {
    this.rules.push({ kind: "uuid", message });
    return this;
  }
  date(message?: string): this {
    this.rules.push({ kind: "date", message });
    return this;
  }
  length(n: number, message?: string): this {
    this.rules.push({ kind: "length", n, message });
    return this;
  }
  oneOf<const TValues extends readonly string[]>(
    values: TValues,
    message?: string,
  ): StringSchema<TValues extends readonly [string, ...string[]] ? TValues[number] : TValue> {
    this.rules.push({ kind: "oneOf", values: [...values], message });
    return this as unknown as StringSchema<
      TValues extends readonly [string, ...string[]] ? TValues[number] : TValue
    >;
  }
  trim(): this {
    this._trim = true;
    return this;
  }
  pattern(re: RegExp, message?: string): this {
    this.rules.push({ kind: "pattern", source: re.source, flags: re.flags, message });
    return this;
  }
  describe(): FieldDescriptor {
    return { ...super.describe(), trim: this._trim || undefined };
  }
}

export class NumberSchema<TValue extends number = number> extends FieldSchema {
  /** Type-only marker used to preserve numeric literal unions through schema inference. */
  declare readonly __value: TValue;
  readonly type = "number" as const;
  integer(message?: string): this {
    this.rules.push({ kind: "integer", message });
    return this;
  }
  positive(message?: string): this {
    this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" });
    return this;
  }
  oneOf<const TValues extends readonly number[]>(
    values: TValues,
    message?: string,
  ): NumberSchema<TValues extends readonly [number, ...number[]] ? TValues[number] : TValue> {
    this.rules.push({ kind: "oneOf", values: [...values], message });
    return this as unknown as NumberSchema<
      TValues extends readonly [number, ...number[]] ? TValues[number] : TValue
    >;
  }
}

export class BooleanSchema extends FieldSchema {
  readonly type = "boolean" as const;
}

export class UnknownSchema extends FieldSchema {
  readonly type = "unknown" as const;
}

export type AnyFieldSchema =
  StringSchema<string> | NumberSchema<number> | BooleanSchema | UnknownSchema;

/** Infer the runtime value produced by a field schema. */
export type InferFieldValue<TField extends FieldSchema> =
  TField extends StringSchema<infer TValue>
    ? TValue
    : TField extends NumberSchema<infer TValue>
      ? TValue
      : TField extends BooleanSchema
        ? boolean
        : unknown;

/** Infer the validated object produced by a field map. */
export type InferObjectFields<TFields extends Record<string, FieldSchema>> = {
  [K in keyof TFields]: InferFieldValue<TFields[K]>;
};

/** Infer the object value produced by an object schema. */
export type InferSchema<TSchema extends ObjectSchema> =
  TSchema extends ObjectSchema<infer TValue> ? TValue : never;

export class ObjectSchema<TValue extends object = Record<string, unknown>> {
  /** Type-only marker used by helper functions to infer validated output. */
  declare readonly __output: TValue;

  constructor(private readonly fields: Record<string, FieldSchema>) {}

  /** Return a defensive copy of the schema fields. */
  getFields(): Readonly<Record<string, FieldSchema>> {
    return { ...this.fields };
  }

  /** Create a new schema with fields added or replaced. The original is unchanged. */
  extend<TFields extends Record<string, FieldSchema>>(
    fields: TFields,
  ): ObjectSchema<Omit<TValue, keyof TFields> & InferObjectFields<TFields>> {
    return new ObjectSchema({ ...this.fields, ...fields });
  }

  /** Create a new schema containing fields from both schemas. */
  merge<TOther extends object>(schema: ObjectSchema<TOther>): ObjectSchema<TValue & TOther> {
    return new ObjectSchema({ ...this.fields, ...schema.getFields() });
  }

  /** Validate an input object; returns coerced values + per-field errors. */
  parse(input: unknown): ParseResult<TValue> {
    const source = (input ?? {}) as Record<string, unknown>;
    const value: Record<string, unknown> = {};
    const errors: Record<string, string> = {};
    for (const [name, field] of Object.entries(this.fields)) {
      const { value: coerced, error } = checkField(field.describe(), source[name]);
      if (error) {
        errors[name] = error;
        continue;
      }
      let finalValue = coerced;
      if (finalValue === undefined) {
        const fallback = field.getDefault();
        if (fallback !== undefined) finalValue = fallback;
      }
      if (finalValue !== undefined) {
        const refineError = field.runRefinements(finalValue);
        if (refineError) {
          errors[name] = refineError;
          continue;
        }
        value[name] = finalValue;
      }
    }
    return { ok: Object.keys(errors).length === 0, value: value as TValue, errors };
  }

  describe(): SchemaDescriptor {
    const fields: Record<string, FieldDescriptor> = {};
    for (const [name, field] of Object.entries(this.fields)) fields[name] = field.describe();
    return { type: "object", fields };
  }
}

/** The fluent schema builder. */
export const v = {
  string: () => new StringSchema<string>(),
  number: () => new NumberSchema<number>(),
  boolean: () => new BooleanSchema(),
  unknown: () => new UnknownSchema(),
  object: <TFields extends Record<string, FieldSchema>>(fields: TFields) =>
    new ObjectSchema<InferObjectFields<TFields>>(fields),
};

// --- Environment configuration --------------------------------------------

/** Read the ambient environment (Bun.env, falling back to process.env). */
function readEnv(): Record<string, string | undefined> {
  const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
  if (bun?.env) return bun.env;
  const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
  return proc?.env ?? {};
}

/**
 * Validate environment variables against a schema at startup. Values are read
 * from `Bun.env` / `process.env` by default and coerced by the schema (so
 * `PORT` becomes a number, `DEBUG` a boolean). On any problem it throws ONE
 * readable error listing every offending variable, so misconfiguration fails
 * fast with an actionable message instead of surfacing deep inside the app.
 *
 *   export const env = parseEnv(v.object({
 *     DATABASE_URL: v.string().min(1),
 *     PORT: v.number(),
 *   }));
 */
export function parseEnv<T extends object>(
  schema: ObjectSchema<T>,
  source: Record<string, string | undefined> = readEnv(),
): T {
  const result = schema.parse(source);
  if (!result.ok) {
    const lines = Object.entries(result.errors).map(([name, message]) => `  • ${name}: ${message}`);
    throw new Error(`Invalid environment variables:\n${lines.join("\n")}`);
  }
  return result.value;
}

// --- API helpers -----------------------------------------------------------

/** A 400 response carrying field errors, for API routes. */
export function invalid(errors: Record<string, string>): Response {
  return Response.json({ ok: false, errors }, { status: 400 });
}

/**
 * Parse a request's JSON body against a schema. On failure returns
 * `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
 */
export async function parseBody<T extends object>(
  schema: ObjectSchema<T>,
  req: Request,
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
  const body = await readBody(req);
  const result = schema.parse(body);
  if (!result.ok) return { ok: false, response: invalid(result.errors) };
  return { ok: true, value: result.value };
}

/** Read a request body as an object from JSON, form-urlencoded, or multipart. */
async function readBody(req: Request): Promise<Record<string, unknown>> {
  const contentType = req.headers.get("content-type") ?? "";
  try {
    if (contentType.includes("application/json")) {
      return (await req.json()) as Record<string, unknown>;
    }
    if (
      contentType.includes("application/x-www-form-urlencoded") ||
      contentType.includes("multipart/form-data")
    ) {
      const out: Record<string, unknown> = {};
      for (const [key, value] of await req.formData()) out[key] = value;
      return out;
    }
    // Best effort: try JSON, else treat as empty.
    return (await req.json()) as Record<string, unknown>;
  } catch {
    return {};
  }
}

export { renderSchemasScript, VALIDATE_RUNTIME } from "./runtime.ts";
export {
  AsyncObjectSchema,
  asyncSchema,
  parseBodyAsync,
  schemaToOpenApi,
  mergeValidationResults,
} from "./advanced.ts";
export type { AsyncValidationContext, AsyncRefinement, OpenApiSchema } from "./advanced.ts";
export {
  ValidationError,
  parseOrThrow,
  validationResponse,
  firstValidationError,
  validationSummary,
  schemaFieldNames,
} from "./helpers.ts";
export { validationPlugin, validationComponentsDir } from "./plugin.ts";
export type { ValidationPluginOptions } from "./plugin.ts";
export {
  toJsonSchema,
  openApiRequestBody,
  localizeDescriptor,
  parseDescriptor,
} from "./interop.ts";
export type {
  JsonSchemaDocument,
  ValidationMessageKey,
  ValidationMessageTranslator,
} from "./interop.ts";
export {
  ContractRegistry,
  checkContractCompatibility,
  defineContract,
  defineEvent,
} from "./contracts.ts";
export type {
  ContractDefinition,
  ContractIssue,
  ContractKind,
  ContractRecord,
  ContractSnapshot,
} from "./contracts.ts";
