import type { CleanSubmission, FieldDef, FieldError, FormDef, Result, SubmissionValue } from './types.js'

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const TEL_RE = /^[+()\-\s\d]{3,}$/

/**
 * Default per-field stored-string ceiling — the payload-size DoS floor when a field omits an
 * explicit `max`. The module PERSISTS validated values, so an unbounded declared field is a
 * storage-amplification vector the module can and does bound (defense-in-depth). The host still
 * caps request-body bytes (parse-time); this caps what gets STORED. Generous (100k chars) so no
 * realistic free-text submission is rejected; an adopter sets an explicit `max` to tighten it.
 */
const DEFAULT_MAX_TEXT = 100_000

/** FormData→Record for the anti-spam honeypot check. Multi-value keys collapse to arrays. */
export function toRecord(raw: Record<string, unknown> | FormData): Record<string, unknown> {
  if (typeof FormData !== 'undefined' && raw instanceof FormData) {
    const out: Record<string, unknown> = {}
    for (const key of new Set(raw.keys())) {
      const all = raw.getAll(key)
      out[key] = all.length > 1 ? all.map(String) : all[0]
    }
    return out
  }
  return raw as Record<string, unknown>
}

function readField(
  raw: Record<string, unknown> | FormData,
  name: string,
  multi: boolean,
): { present: boolean; value: unknown } {
  if (typeof FormData !== 'undefined' && raw instanceof FormData) {
    if (!raw.has(name)) return { present: false, value: undefined }
    if (multi) return { present: true, value: raw.getAll(name).map(String) }
    const v = raw.get(name)
    return { present: true, value: v === null ? undefined : v }
  }
  const rec = raw as Record<string, unknown>
  // own-property only — never inherit __proto__ etc.
  if (!Object.prototype.hasOwnProperty.call(rec, name)) return { present: false, value: undefined }
  return { present: true, value: rec[name] }
}

type Coerced = { value: SubmissionValue } | { error: FieldError['code']; message: string }

function checkPattern(f: FieldDef, s: string): { error: 'pattern'; message: string } | null {
  if (f.pattern === undefined) return null
  let re: RegExp
  try {
    re = new RegExp(f.pattern)
  } catch {
    return null // a bad pattern source is caught by defineForm; do not fail a submission on it
  }
  if (!re.test(s)) return { error: 'pattern', message: `${f.name} does not match the required pattern` }
  return null
}

function coerceField(f: FieldDef, value: unknown): Coerced {
  switch (f.type) {
    case 'number': {
      const n = typeof value === 'number' ? value : Number(String(value).trim())
      if (!Number.isFinite(n)) return { error: 'type', message: `${f.name} must be a number` }
      if (f.min !== undefined && n < f.min) return { error: 'min', message: `${f.name} must be >= ${f.min}` }
      if (f.max !== undefined && n > f.max) return { error: 'max', message: `${f.name} must be <= ${f.max}` }
      return { value: n }
    }
    case 'multiselect': {
      const arr = (Array.isArray(value) ? value : [value]).map(String)
      const allowed = new Set((f.options ?? []).map((o) => o.value))
      for (const v of arr) {
        // never echo raw input back (spec §3): report the field, not the attacker's value
        if (!allowed.has(v)) return { error: 'option', message: `${f.name} contains a value that is not an allowed option` }
      }
      if (f.min !== undefined && arr.length < f.min) return { error: 'min', message: `${f.name} requires at least ${f.min}` }
      // count cap: explicit max, else the number of declared options (multiselect is set-semantics —
      // you cannot legitimately select more distinct values than exist; bounds a repeat-flood DoS).
      const maxCount = f.max ?? (f.options?.length ?? 0)
      if (arr.length > maxCount) return { error: 'max', message: `${f.name} allows at most ${maxCount}` }
      return { value: arr }
    }
    case 'select':
    case 'radio': {
      const s = String(value)
      const allowed = new Set((f.options ?? []).map((o) => o.value))
      // never echo raw input back (spec §3): report the field, not the attacker's value
      if (!allowed.has(s)) return { error: 'option', message: `${f.name} is not an allowed option` }
      return { value: s }
    }
    case 'email': {
      const s = String(value).trim()
      // payload-size floor first — never run a regex over an unbounded attacker string
      if (s.length > DEFAULT_MAX_TEXT) return { error: 'max', message: `${f.name} is too long` }
      if (!EMAIL_RE.test(s)) return { error: 'type', message: `${f.name} must be a valid email` }
      return checkPattern(f, s) ?? { value: s }
    }
    case 'url': {
      const s = String(value).trim()
      if (s.length > DEFAULT_MAX_TEXT) return { error: 'max', message: `${f.name} is too long` }
      try {
        new URL(s)
      } catch {
        return { error: 'type', message: `${f.name} must be a valid URL` }
      }
      return checkPattern(f, s) ?? { value: s }
    }
    case 'tel': {
      const s = String(value).trim()
      if (s.length > DEFAULT_MAX_TEXT) return { error: 'max', message: `${f.name} is too long` }
      if (!TEL_RE.test(s)) return { error: 'type', message: `${f.name} must be a valid phone number` }
      return checkPattern(f, s) ?? { value: s }
    }
    case 'date': {
      const s = String(value).trim()
      if (s.length > DEFAULT_MAX_TEXT) return { error: 'max', message: `${f.name} is too long` }
      if (Number.isNaN(Date.parse(s))) return { error: 'type', message: `${f.name} must be a valid date` }
      return checkPattern(f, s) ?? { value: s }
    }
    case 'text':
    case 'textarea':
    case 'hidden':
    default: {
      const s = String(value)
      // length cap: explicit max, else the DoS-floor default (a field with no max is still bounded)
      const maxLen = f.max ?? DEFAULT_MAX_TEXT
      if (f.min !== undefined && s.length < f.min) return { error: 'min', message: `${f.name} must be at least ${f.min} characters` }
      if (s.length > maxLen) return { error: 'max', message: `${f.name} must be at most ${maxLen} characters` }
      return checkPattern(f, s) ?? { value: s }
    }
  }
}

/**
 * HARD FLOOR. Validate + coerce an untrusted submission against a form.
 * Drops undeclared keys (only declared fields appear in the result), enforces
 * required/min/max/pattern/options, returns typed FieldError[] on failure.
 */
export function validateSubmission(
  form: FormDef,
  raw: Record<string, unknown> | FormData,
): Result<CleanSubmission, FieldError[]> {
  const errors: FieldError[] = []
  const clean: CleanSubmission = {}

  for (const f of form.fields) {
    // checkbox: presence-based boolean, always included
    if (f.type === 'checkbox') {
      const { present, value } = readField(raw, f.name, false)
      const checked = present && value !== false && value !== '' && value !== 'false' && value != null
      if (f.required && !checked) {
        errors.push({ field: f.name, code: 'required', message: `${f.name} is required` })
        continue
      }
      clean[f.name] = checked
      continue
    }

    const multi = f.type === 'multiselect'
    const { present, value } = readField(raw, f.name, multi)
    const isEmpty =
      !present ||
      value === undefined ||
      value === null ||
      value === '' ||
      (Array.isArray(value) && value.length === 0)

    if (isEmpty) {
      if (f.required) {
        errors.push({ field: f.name, code: 'required', message: `${f.name} is required` })
      } else if (f.default !== undefined) {
        clean[f.name] = f.default as SubmissionValue
      }
      // optional + absent => omitted from the clean record
      continue
    }

    const res = coerceField(f, value)
    if ('error' in res) errors.push({ field: f.name, code: res.error, message: res.message })
    else clean[f.name] = res.value
  }

  if (errors.length > 0) return { ok: false, error: errors }
  return { ok: true, value: clean }
}
