/**
 * Validation telemetry — connect runtime input validation (Zod or any
 * `safeParse` schema) to your traces and metrics at the boundaries where bad
 * data actually enters: HTTP bodies, events, messages.
 *
 * Today a `safeParse` failure either throws (no span, no metric, no alert) or
 * is silently swallowed in a handler. `defineValidator` makes the mismatch
 * **observable** — a `validation.*` span attribute set and a counter
 * incremented — with a per-validator `observe` vs `reject` mode:
 *
 * - `reject` (default): record telemetry, then throw a structured 400-shaped
 *   error so the boundary can fail cleanly.
 * - `observe`: record telemetry, return the raw input so the handler continues
 *   — useful for measuring real-world drift before you enforce it.
 *
 * **Not a security feature by default.** A malformed body is usually a bug or
 * version skew, not an attack. Validation telemetry is first-class on its own
 * metric; escalation to the security path is a deliberate opt-in via
 * {@link onValidationMismatch} (e.g. wired by `autotel-audit`), never automatic.
 *
 * **PII-safe by construction.** Only field *paths*, issue *codes*, and the
 * declared *type* are ever recorded — never the offending value, and never a
 * validator's error `message` (which routinely embeds the received value).
 */

import { trace } from '@opentelemetry/api';
import { createCounter } from './metric-helpers';
import { createStructuredError, type StructuredError } from './structured-error';
import { hashJson } from './stable-hash';
import type { SchemaLike } from './define-event';
import {
  VALIDATION_ATTR,
  VALIDATION_ISSUE_CAP,
  VALIDATION_METRICS,
} from './validation-attributes';

export type { SchemaLike } from './define-event';

export type ValidationMode = 'observe' | 'reject';
export type ValidationSeverity = 'info' | 'warning' | 'error';

/** A single failing field, stripped of any payload values. */
export interface ValidationIssue {
  /** Dotted field path, e.g. `items.0.price`. Never a value. */
  path: string;
  /** Issue code (e.g. Zod's `invalid_type`, `too_small`). Never a value. */
  code: string;
  /** Declared type/constraint summary, e.g. `string`. Never a received value. */
  expected?: string;
}

/** Everything the recorder needs — already PII-stripped by the caller. */
export interface ValidationMismatch {
  /** Contract id, e.g. `POST /orders` or `order.placed`. */
  name: string;
  boundary: string;
  mode: ValidationMode;
  issues: ValidationIssue[];
  hash?: string;
  severity?: ValidationSeverity;
}

let mismatchCounter: ReturnType<typeof createCounter> | undefined;
function counter(): ReturnType<typeof createCounter> {
  if (!mismatchCounter) {
    mismatchCounter = createCounter(VALIDATION_METRICS.mismatches, {
      description: 'Input payloads that did not match their declared shape',
    });
  }
  return mismatchCounter;
}

type MismatchListener = (mismatch: ValidationMismatch) => void;
const listeners = new Set<MismatchListener>();

/**
 * Register an explicit handler called on every recorded mismatch — the opt-in
 * seam for escalating to security events, a webhook, or a custom sink. There is
 * no automatic, package-presence-driven escalation: nothing fires here unless
 * you (or a package you wire up) register a handler.
 *
 * Multiple subscribers coexist: a package (e.g. `autotel-audit` bridging to
 * security events) and your own app code (a webhook, a logger) can both
 * register and all fire. Returns an unsubscribe fn that removes only this
 * handler; registering the same function twice is a no-op (Set semantics).
 */
export function onValidationMismatch(handler: MismatchListener): () => void {
  listeners.add(handler);
  return () => {
    listeners.delete(handler);
  };
}

const truncate = (values: string[]): string =>
  values.slice(0, VALIDATION_ISSUE_CAP).join(',');

/**
 * Record a validation mismatch as telemetry: `validation.*` attributes on the
 * active span (if any) and an increment on `autotel.validation.mismatches`.
 * Fail-open — never throws, so instrumentation can't break the boundary.
 */
export function recordValidationMismatch(mismatch: ValidationMismatch): void {
  try {
    const paths = mismatch.issues.map((i) => i.path).filter(Boolean);
    const codes = [...new Set(mismatch.issues.map((i) => i.code))];

    const span = trace.getActiveSpan();
    if (span) {
      span.setAttributes({
        [VALIDATION_ATTR.name]: mismatch.name,
        [VALIDATION_ATTR.boundary]: mismatch.boundary,
        [VALIDATION_ATTR.mode]: mismatch.mode,
        [VALIDATION_ATTR.issueCount]: mismatch.issues.length,
        [VALIDATION_ATTR.issuePaths]: truncate(paths),
        [VALIDATION_ATTR.issueCodes]: truncate(codes),
        ...(mismatch.hash ? { [VALIDATION_ATTR.hash]: mismatch.hash } : {}),
        ...(mismatch.severity
          ? { [VALIDATION_ATTR.severity]: mismatch.severity }
          : {}),
      });
    }

    try {
      counter().add(1, {
        boundary: mismatch.boundary,
        validation: mismatch.name,
        mode: mismatch.mode,
      });
    } catch {
      // meter not initialised yet — skip the count, keep the span attrs
    }

    // Dispatch to every subscriber with per-listener fault isolation: one
    // throwing subscriber must not starve its peers or break the boundary.
    // Set iteration tolerates concurrent (un)subscription safely.
    for (const listener of listeners) {
      try {
        listener(mismatch);
      } catch {
        // a misbehaving subscriber must not break the boundary or its peers
      }
    }
  } catch {
    // fail-open: telemetry must never break the validated boundary
  }
}

/**
 * Normalise an arbitrary validation error into PII-safe issues. Reads only
 * `path`, `code`, and (when it is a declared type name) `expected` — and never
 * `message`, `received`, or any value-bearing field. Understands the Zod shape
 * (`error.issues`) and a generic `error.errors` fallback; returns `[]` for
 * anything unrecognised.
 */
export function formatValidationIssues(error: unknown): ValidationIssue[] {
  const raw = extractRawIssues(error);
  return raw.map((issue) => toSafeIssue(issue));
}

function extractRawIssues(error: unknown): Array<Record<string, unknown>> {
  if (error && typeof error === 'object') {
    const candidate =
      (error as { issues?: unknown }).issues ??
      (error as { errors?: unknown }).errors;
    if (Array.isArray(candidate)) {
      return candidate.filter(
        (i): i is Record<string, unknown> => i !== null && typeof i === 'object',
      );
    }
  }
  return [];
}

function toSafeIssue(issue: Record<string, unknown>): ValidationIssue {
  const rawPath = issue.path;
  const path = Array.isArray(rawPath)
    ? rawPath.map(String).join('.')
    : typeof rawPath === 'string'
      ? rawPath
      : '';
  const code = typeof issue.code === 'string' ? issue.code : 'invalid';
  // `expected` is a declared type name in Zod (e.g. 'string'); safe. We never
  // read `received`/`message`/`value`, which can carry the offending payload.
  const expected =
    typeof issue.expected === 'string' ? issue.expected : undefined;
  return expected ? { path, code, expected } : { path, code };
}

export interface DefineValidatorOptions<S> {
  /** Where validation runs. Defaults to `input`. */
  boundary?: string;
  /** `reject` (default): record then throw. `observe`: record then continue. */
  onMismatch?: ValidationMode;
  /** Project the schema to JSON Schema for a stable `validation.hash`. */
  toJsonSchema?: (schema: S) => unknown;
  severity?: ValidationSeverity;
  /** Build the error thrown in `reject` mode (defaults to a 400 structured error). */
  onReject?: (issues: ValidationIssue[], name: string) => Error;
}

export type ValidatorResult<T> =
  | { success: true; data: T }
  | { success: false; issues: ValidationIssue[] };

export interface Validator<T> {
  readonly name: string;
  readonly mode: ValidationMode;
  /** Validate and record on failure; never throws. */
  safeParse(input: unknown): ValidatorResult<T>;
  /**
   * Validate, record on failure, then apply the mode: `reject` throws,
   * `observe` returns the raw input so the handler can continue.
   */
  parse(input: unknown): T;
}

function defaultRejectError(
  issues: ValidationIssue[],
  name: string,
): StructuredError {
  return createStructuredError({
    name: 'ValidationError',
    status: 400,
    code: 'validation_failed',
    message: `Input for "${name}" did not match its declared shape.`,
    why: `${issues.length} field(s) failed validation: ${issues
      .map((i) => i.path || '(root)')
      .slice(0, VALIDATION_ISSUE_CAP)
      .join(', ')}.`,
    fix: 'Send a payload that matches the schema, or switch this validator to observe mode while you investigate.',
    // PII-safe: paths + codes only, no received values.
    details: { validation: name, issues },
  });
}

/**
 * Declare an expected input shape once and get a validator that records every
 * mismatch as telemetry.
 *
 * @example
 * ```ts
 * import { z } from 'zod';
 * import { defineValidator } from 'autotel/validate';
 *
 * const OrderBody = defineValidator('POST /orders', z.object({
 *   items: z.array(z.object({ sku: z.string(), qty: z.number().int() })),
 * }), { boundary: 'http', toJsonSchema: (s) => z.toJSONSchema(s) });
 *
 * // reject mode (default): records + throws a 400-shaped structured error
 * const order = OrderBody.parse(req.body);
 *
 * // observe mode: records, returns the result, never throws
 * const result = OrderBody.safeParse(req.body);
 * if (!result.success) metrics.onDrift(result.issues);
 * ```
 */
export function defineValidator<T, S extends SchemaLike<T>>(
  name: string,
  schema: S,
  options: DefineValidatorOptions<S> = {},
): Validator<T> {
  const mode = options.onMismatch ?? 'reject';
  const boundary = options.boundary ?? 'input';
  const hash = options.toJsonSchema
    ? hashJson(options.toJsonSchema(schema))
    : undefined;

  const record = (issues: ValidationIssue[]): void => {
    recordValidationMismatch({
      name,
      boundary,
      mode,
      issues,
      hash,
      severity: options.severity,
    });
  };

  return {
    name,
    mode,
    safeParse(input: unknown): ValidatorResult<T> {
      const parsed = schema.safeParse(input);
      if (parsed.success) return { success: true, data: parsed.data };
      const issues = formatValidationIssues(parsed.error);
      record(issues);
      return { success: false, issues };
    },
    parse(input: unknown): T {
      const parsed = schema.safeParse(input);
      if (parsed.success) return parsed.data;
      const issues = formatValidationIssues(parsed.error);
      record(issues);
      if (mode === 'reject') {
        throw options.onReject?.(issues, name) ?? defaultRejectError(issues, name);
      }
      // observe: continue with the raw input (documented type caveat)
      return input as T;
    },
  };
}
