# Form Contracts — declarative HTML forms, served and delivered WITHOUT a build.
#
# ─────────────────────────────────────────────────────────────────────────────
# WHY THIS EXISTS
#
# Before this, every form meant a typed `struct` in Rust, a handler, a `nest(...)` and a
# RECOMPILE of the shared image. So a form was the one part of a page you could not add: the
# template, the Fluent text, the content and (since `merge_site_routes`) the route were all
# already the consumer's. The endpoint was the last baked thing.
#
# The result was predictable and it is on the record: `work-request` — a public
# "request a project quote" form with eleven fields — has a template and NO handler.
# `POST /api/work-request` is registered nowhere in the stack. The page renders, the visitor
# fills it in, and the submission goes to a route that does not exist. It has been like that
# long enough that nobody noticed, because a form that renders LOOKS like a form that works.
#
# And the naive repair was worse. `ContactForm` (htmx_contact.rs) declares four fields and does
# NOT set `deny_unknown_fields`, so serde SILENTLY DROPS anything it does not know. Wiring
# work-request to it would have returned 200, said "sent", and delivered an email containing the
# name and the email address — with the project description, the budget and the timeline gone.
# A form that appears to work and throws the data away is worse than one that visibly fails.
#
# THE FIX IS THE SAME MOVE AS THE ROUTES. The form is DECLARED, in NCL, by the site. One generic
# handler reads the declaration and delivers what the declaration names. A new form is a `.ncl`
# and a template — no Rust, no build.
#
# And it can be NCL rather than TOML precisely because the runtime already reads NCL: the htmx
# shell loads `routes.ncl` and `footer.ncl` through `rustelo_config::format::load_config`, which
# dispatches `.ncl` to `nickel::export_to_json`. No new dependency; the one that is already there.
#
# WHAT THE CONTRACT BUYS OVER A BARE MAP. The handler accepts a `HashMap<String,String>` — no
# per-form struct, which is the whole point. The contract is what keeps that from becoming a
# free-for-all:
#
#   · a field not in `fields` is REFUSED, not silently dropped. The failure mode that made this
#     necessary cannot recur here — it is exactly what serde's unknown-field default did.
#   · `required` is checked before delivery, not after.
#   · `deliver_to` names an env var, never a literal address: a recipient in git is a recipient
#     harvested.
#   · every declared field appears in the composed body, in declaration order. What the form
#     collects is what the owner receives.
# ─────────────────────────────────────────────────────────────────────────────

let field_kind = [|
  'Text,
  'Email,
  'Textarea,
  'Select,
  # A honeypot. Rendered hidden; a non-empty value means a bot, and the handler confirms
  # WITHOUT delivering, so the bot gets no signal that it was caught.
  'Honeypot,
|] in

let field_type = {
  # The form-encoded name. This is the contract with the template: a field the template posts
  # and the spec does not declare is REFUSED by the handler.
  name     | String,
  kind     | field_kind | default = 'Text,
  required | Bool | default = false,
  # Fluent key for the label. The text lives where all text lives.
  label_key | String | default = "",
  # Fluent key for the placeholder / help text.
  hint_key  | String | default = "",
  # For 'Select: the option values. Their labels are `<label_key>-<value>` in Fluent.
  options   | Array String | default = [],
  # Included in the delivered body. False for a honeypot, and for anything the owner does not
  # need to read. Default true: a field that is collected and NOT delivered is the bug this
  # whole contract was written after.
  deliver   | Bool | default = true,
} in

let RequiredFieldsExist = std.contract.from_validator (fun spec =>
  let names = spec.fields |> std.array.map (fun f => f.name) in
  let missing = spec.required |> std.array.filter (fun r => !(std.array.elem r names)) in
  if std.array.length missing == 0 then
    'Ok
  else
    'Error {
      message = "`required` names fields that are not declared: " ++ std.string.join ", " missing,
      notes = ["A required field the form never renders can never be filled in."],
    }
) in

# Nothing declared twice: two entries for one field name is not a form, it is a race between
# whichever the handler reads last.
let FieldNamesUnique = std.contract.from_validator (fun spec =>
  let names = spec.fields |> std.array.map (fun f => f.name) in
  let dups =
    names
    |> std.array.filter (fun n => (names |> std.array.filter (fun m => m == n) |> std.array.length) > 1)
  in
  if std.array.length dups == 0 then
    'Ok
  else
    'Error { message = "duplicate field name: " ++ std.string.join ", " dups }
) in

# The recipient is an ENV VAR NAME, never an address. An address in a config file is an address
# in git, and an address in git is an address in a scraper's list.
let RecipientIsEnvVar = std.contract.from_validator (fun v =>
  if std.string.is_match "^[A-Z][A-Z0-9_]*$" v then
    'Ok
  else
    'Error {
      message = "`deliver_to` must be an ENV VAR NAME (e.g. WORK_REQUEST_RECIPIENT), got: " ++ v,
      notes = ["A literal address here is a literal address in the repository."],
    }
) in

let form_spec_type = {
  # Matches the URL: POST /api/htmx/form/<id>, and the file name `<id>.ncl`.
  id | String,

  # The env var holding the destination address. Unset at runtime ⇒ the submission is logged and
  # the visitor still gets a confirmation: a form that 500s because the OWNER forgot to configure
  # it punishes the wrong person.
  deliver_to | String | RecipientIsEnvVar,

  # The subject line. `{field}` interpolates a submitted value; an unknown field interpolates to
  # nothing rather than failing, because a subject line is not worth losing a lead over.
  subject | String | default = "",

  # Fluent keys for the two response fragments the handler returns.
  success_key | String | default = "",
  error_key   | String | default = "",

  fields   | Array field_type,
  required | Array String | default = [],
} in

{
  FieldKind = field_kind,
  Field     = field_type,

  FormSpec = std.contract.Sequence [
    form_spec_type,
    RequiredFieldsExist,
    FieldNamesUnique,
  ],

  # Build a spec. `required` defaults to every field marked `required = true`, so the two cannot
  # disagree — an earlier design had them as independent lists and that is a contradiction waiting.
  make_form = fun data =>
    # The contract must be applied to each field BEFORE reading `required` off it — a raw record
    # carries no defaults, and `f.required` on one is a missing field, not `false`.
    let flds = data.fields |> std.array.map (fun f => field_type & f) in
    let req = flds |> std.array.filter (fun f => f.required) |> std.array.map (fun f => f.name) in
    # `data` must lose its own `fields` before the merge: `data & { fields = <derived from
    # data.fields> }` is a record referring to itself, and Nickel calls that what it is.
    ((std.record.remove "fields" data) & { fields = flds, required = req })
    | (std.contract.Sequence [form_spec_type, RequiredFieldsExist, FieldNamesUnique]),
}
