///|
pub(all) enum Coverage {
  Passed
  Failed
  Incomplete
} derive(Eq, Debug)

///|
pub(all) enum ValidationProfile {
  Specification
  MetaschemaCompatibility
} derive(Eq, Debug)

///|
pub(all) enum Severity {
  Error
  Warning
  Information
} derive(Eq, Debug)

///|
pub(all) enum Phase {
  Model
  SchemaDocument
  References
  Normative
  Artifact
} derive(Eq, Debug)

///|
/// Where a finding applies. `resource` is None for the unnamed input document.
/// Spans exist only for the input document, and only when its source map was
/// supplied.
pub struct FindingLocation {
  priv resource : @reference.UriReference?
  priv path : Array[String]
  priv span : @exact.Span?
  priv source_id : String?
} derive(Debug)

///|
pub fn FindingLocation::resource(
  self : FindingLocation,
) -> @reference.UriReference? {
  self.resource
}

///|
pub fn FindingLocation::path(self : FindingLocation) -> Array[String] {
  self.path.copy()
}

///|
pub fn FindingLocation::span(self : FindingLocation) -> @exact.Span? {
  self.span
}

///|
pub fn FindingLocation::source_id(self : FindingLocation) -> String? {
  self.source_id
}

///|
pub struct Finding {
  diagnostic : @exact.Diagnostic
  severity : Severity
  phase : Phase
  priv location : FindingLocation
  priv related : Array[FindingLocation]
  priv chain : Array[FindingLocation]
  priv expected : String?
  priv discrepancy : Discrepancy?
  priv version : DocumentVersion
  priv profile : ValidationProfile
} derive(Debug)

///|
pub fn Finding::diagnostic(self : Finding) -> @exact.Diagnostic {
  self.diagnostic
}

///|
pub fn Finding::severity(self : Finding) -> Severity {
  self.severity
}

///|
pub fn Finding::phase(self : Finding) -> Phase {
  self.phase
}

///|
pub fn Finding::location(self : Finding) -> FindingLocation {
  self.location
}

///|
/// Other occurrences involved, such as the first of two duplicates.
pub fn Finding::related(self : Finding) -> Array[FindingLocation] {
  self.related.copy()
}

///|
/// OpenRPC `$ref` sites followed, in order, to reach the location.
pub fn Finding::chain(self : Finding) -> Array[FindingLocation] {
  self.chain.copy()
}

///|
/// The missing member's name when the location is its parent object.
pub fn Finding::expected_field(self : Finding) -> String? {
  self.expected
}

///|
/// The recorded prose/artifact discrepancy this normative finding belongs to.
pub fn Finding::discrepancy(self : Finding) -> Discrepancy? {
  self.discrepancy
}

///|
pub fn Finding::version(self : Finding) -> DocumentVersion {
  self.version
}

///|
pub fn Finding::profile(self : Finding) -> ValidationProfile {
  self.profile
}

///|
pub struct ValidationReport {
  profile : ValidationProfile
  version : DocumentVersion
  findings : Array[Finding]
  model : Coverage
  schemas : Coverage
  references : Coverage
  normative : Coverage
  artifact : Coverage
} derive(Debug)

///|
pub fn ValidationReport::profile(self : ValidationReport) -> ValidationProfile {
  self.profile
}

///|
pub fn ValidationReport::version(self : ValidationReport) -> DocumentVersion {
  self.version
}

///|
/// Findings in deterministic order: resource, pointer tokens, code, source
/// occurrence, then message.
pub fn ValidationReport::findings(self : ValidationReport) -> Array[Finding] {
  self.findings.copy()
}

///|
pub fn ValidationReport::model(self : ValidationReport) -> Coverage {
  self.model
}

///|
pub fn ValidationReport::schemas(self : ValidationReport) -> Coverage {
  self.schemas
}

///|
pub fn ValidationReport::references(self : ValidationReport) -> Coverage {
  self.references
}

///|
pub fn ValidationReport::normative(self : ValidationReport) -> Coverage {
  self.normative
}

///|
pub fn ValidationReport::artifact(self : ValidationReport) -> Coverage {
  self.artifact
}

///|
/// A document whose required facets all passed for one profile and one
/// explicit resource snapshot.
pub struct ValidatedDocument {
  document : Document
  report : ValidationReport
  priv resources : Array[@reference.UriReference]
} derive(Debug)

///|
pub fn ValidatedDocument::document(self : ValidatedDocument) -> Document {
  self.document
}

///|
pub fn ValidatedDocument::report(self : ValidatedDocument) -> ValidationReport {
  self.report
}

///|
pub fn ValidatedDocument::profile(
  self : ValidatedDocument,
) -> ValidationProfile {
  self.report.profile
}

///|
pub fn ValidatedDocument::version(self : ValidatedDocument) -> DocumentVersion {
  self.document.version
}

///|
/// Retrieval URIs of the resource snapshot the document was closed against.
pub fn ValidatedDocument::resources(
  self : ValidatedDocument,
) -> Array[@reference.UriReference] {
  self.resources.copy()
}

///|
pub struct ClosedValidation {
  report : ValidationReport
  document : ValidatedDocument?
} derive(Debug)

///|
pub fn ClosedValidation::report(self : ClosedValidation) -> ValidationReport {
  self.report
}

///|
pub fn ClosedValidation::document(
  self : ClosedValidation,
) -> ValidatedDocument? {
  self.document
}

///|
/// A pointer in one resource; None is the unnamed input document.
priv struct Site {
  resource : @reference.UriReference?
  path : Array[String]
}

///|
/// A finding before source lookup, ordering and limits.
priv struct Draft {
  diagnostic : @exact.Diagnostic
  severity : Severity
  phase : Phase
  site : Site
  related : Array[Site]
  chain : Array[Site]
  expected : String?
  discrepancy : Discrepancy?
}

///|
fn add(
  findings : Array[Draft],
  code : @exact.Code,
  message : String,
  path : Array[String],
  phase : Phase,
  severity~ : Severity = Error,
  resource~ : @reference.UriReference? = None,
  related~ : Array[Site] = [],
  chain~ : Array[Site] = [],
  expected~ : String? = None,
  discrepancy~ : Discrepancy? = None,
) -> Unit {
  findings.push({
    diagnostic: @exact.Diagnostic::new(code, message, path~),
    phase,
    severity,
    site: { resource, path },
    related,
    chain,
    expected,
    discrepancy,
  })
}

///|
/// Codes for references whose inputs were not supplied: incomplete, not failed.
fn unavailable(code : @exact.Code) -> Bool {
  code == @exact.MissingBaseUri || code == @exact.UnavailableResource
}

///|
fn reference_severity(code : @exact.Code) -> Severity {
  if unavailable(code) || code == @exact.UnsupportedCheck {
    Information
  } else {
    Error
  }
}

///|
/// One validation pass over a document and its explicit resources. Every
/// reference site resolves through one schema index; targets in other
/// resources are reported at their own resource-relative pointer.
priv struct Check {
  index : @reference.SchemaIndex
  findings : Array[Draft]
  checked : Set[String]
  limits : @exact.Limits
  version : DocumentVersion
  profile : ValidationProfile
  links : Array[(String, Site, Array[Site])]
}

///|
fn site_key(owner : @reference.UriReference?, path : Array[String]) -> String {
  let resource = match owner {
    Some(uri) => uri.identity()
    None => ""
  }
  let pointer = @reference.JsonPointer::from_tokens(
    path,
    limits=@exact.Limits::unlimited(),
  ).unwrap()
  resource + "#" + pointer.to_string()
}

///|
/// A resolved OpenRPC object: its value, owning resource, pointer there, and
/// the `$ref` sites followed to reach it.
priv struct Resolved[T] {
  value : T
  owner : @reference.UriReference?
  path : Array[String]
  chain : Array[Site]
}

///|
/// Follow a reference-only chain to a concrete object. Each reference resolves
/// against the resource that holds it. Revisiting a location is a cycle.
fn Check::follow(
  self : Check,
  value : @exact.Value,
  owner : @reference.UriReference?,
  path : Array[String],
  chain : Array[Site],
) -> Resolved[@exact.Value]? {
  let mut current = value
  let mut owner = owner
  let mut at = path
  let chain = chain.copy()
  let seen : Set[String] = Set([])
  for _ in 0.. {
      add(
        self.findings,
        code,
        message,
        site,
        References,
        severity~,
        resource=owner,
        chain=chain.copy(),
      )
    }
    guard raw.text() is Some(text) else {
      fail(@exact.InvalidReference, "Reference must be a string", Error)
      return None
    }
    let ref_ = match @reference.Reference::parse(text, limits=self.limits) {
      Ok(v) => v
      Err(d) => {
        fail(d.code(), d.message(), Error)
        return None
      }
    }
    let resolution = self.index.resolve(ref_, base=owner, limits=self.limits)
    guard resolution.target() is Some(target) else {
      for d in resolution.diagnostics() {
        fail(d.code(), d.message(), reference_severity(d.code()))
      }
      return None
    }
    if !seen.add_and_check(target.identity()) {
      fail(
        @exact.ReferenceCycle,
        "OpenRPC reference-only cycle has no concrete target",
        Error,
      )
      return None
    }
    chain.push({ resource: owner, path: site })
    current = target.target()
    owner = target.resource()
    at = target.pointer().tokens()
  }
  add(
    self.findings,
    @exact.LimitExceeded,
    "OpenRPC reference chain limit exceeded",
    path,
    References,
    resource=owner,
    chain~,
  )
  None
}

///|
/// Resolve a site and decode its target with the expected kind's checked
/// decoder. A referenced target of another kind is `WrongType` at the site.
fn[T] Check::typed(
  self : Check,
  value : @exact.Value,
  owner : @reference.UriReference?,
  path : Array[String],
  kind : String,
  decode : (@exact.Value, Array[String]) -> T raise DecodeError,
  chain~ : Array[Site] = [],
) -> Resolved[T]? {
  let referenced = value.get("$ref") is Some(_)
  guard self.follow(value, owner, path, chain) is Some(target) else {
    return None
  }
  let decoded = decode(target.value, target.path) catch {
    DecodeError(d) => {
      if referenced {
        let last = target.chain[target.chain.length() - 1]
        add(
          self.findings,
          @exact.WrongType,
          "Reference target is not a valid " + kind + ": " + d.message(),
          last.path,
          References,
          resource=last.resource,
          chain=target.chain[:target.chain.length() - 1].to_owned(),
        )
      } else {
        add(
          self.findings,
          d.code(),
          d.message(),
          d.path(),
          Model,
          resource=target.owner,
          chain=target.chain,
        )
      }
      return None
    }
  }
  Some({
    value: decoded,
    owner: target.owner,
    path: target.path,
    chain: target.chain,
  })
}

///|
fn Check::schema(
  self : Check,
  schema : @schema.Schema,
  owner : @reference.UriReference?,
  path : Array[String],
) -> Unit {
  // Findings depend only on the schema location, so each is checked once no
  // matter how many reference sites reach it.
  if !self.checked.add_and_check("schema " + site_key(owner, path)) {
    return
  }
  let report = @schema.validate_in(
    schema,
    index=self.index,
    resource=owner,
    path~,
    limits=self.limits,
  )
  let owners = report.diagnostic_resources()
  for i, d in report.diagnostics() {
    let ref_finding = d.code() == @exact.UnresolvedReference ||
      d.code() == @exact.InvalidReference ||
      d.code() == @exact.ConflictingIdentifier ||
      unavailable(d.code()) ||
      (d.code() == @exact.UnsupportedCheck && d.path().last() == Some("$ref"))
    add(
      self.findings,
      d.code(),
      d.message(),
      d.path(),
      if ref_finding {
        References
      } else {
        SchemaDocument
      },
      severity=if d.code() == @exact.UnsupportedCheck ||
        d.code() == @exact.UnsupportedDialect ||
        unavailable(d.code()) {
        Information
      } else {
        Error
      },
      resource=owners.get(i).unwrap_or(owner),
    )
  }
  if !report.is_complete() && report.diagnostics().is_empty() {
    add(
      self.findings,
      @exact.UnsupportedCheck,
      "Schema checks incomplete",
      path,
      SchemaDocument,
      severity=Information,
      resource=owner,
    )
  }
}

///|
fn descriptor_decoder(
  limits : @exact.Limits,
) -> (@exact.Value, Array[String]) -> ContentDescriptor raise DecodeError {
  (v, p) => {
    guard descriptor(v, p, limits~) is Inline(d) else {
      raise DecodeError(
        @exact.Diagnostic::new(@exact.WrongType, "Expected an object", path=p),
      )
    }
    d
  }
}

///|
fn Check::pairing(
  self : Check,
  value : @exact.Value,
  owner : @reference.UriReference?,
  path : Array[String],
  chain~ : Array[Site] = [],
) -> Unit {
  let limits = self.limits
  guard self.typed(
      value,
      owner,
      path,
      "example pairing",
      (v, p) => pairing_(v, p, limits),
      chain~,
    )
    is Some(pairing) else {
    return
  }
  if !self.checked.add_and_check("pairing " + site_key(pairing.owner, pairing.path)) {
    return
  }
  self.fields(
    ExamplePairingObject,
    pairing.value.value,
    pairing.owner,
    pairing.path,
    chain=pairing.chain,
  )
  let example = (v, p) => example_(v, p, limits)
  let at = pairing.path
  let examples = []
  if pairing.value.value.get("params") is Some(params) &&
    params.kind() is @exact.Array(items) {
    for i, item in items {
      examples.push(
        self.typed(
          item,
          pairing.owner,
          child(child(at, "params"), i.to_string()),
          "example",
          example,
          chain=pairing.chain,
        ),
      )
    }
  }
  if pairing.value.value.get("result") is Some(result) {
    examples.push(
      self.typed(
        result,
        pairing.owner,
        child(at, "result"),
        "example",
        example,
        chain=pairing.chain,
      ),
    )
  }
  for resolved in examples {
    if resolved is Some(target) {
      self.example(
        target.value.value,
        target.owner,
        target.path,
        chain=target.chain,
      )
    }
  }
}

///|
fn[T] Check::items(
  self : Check,
  method_ : Resolved[Method],
  name : String,
  kind : String,
  decode : (@exact.Value, Array[String]) -> T raise DecodeError,
) -> Array[Resolved[T]] {
  guard method_.value.value.get(name) is Some(raw) &&
    raw.kind() is @exact.Array(items) else {
    return []
  }
  let resolved = []
  for i, item in items {
    if self.typed(
        item,
        method_.owner,
        child(child(method_.path, name), i.to_string()),
        kind,
        decode,
        chain=method_.chain,
      )
      is Some(target) {
      resolved.push(target)
    }
  }
  resolved
}

///|
/// Draft-07 schema positions the typed model knows in a document.
fn document_schema_roots(document : Document) -> Array[Array[String]] {
  let roots = []
  for i, method_ in document.methods {
    guard method_ is Inline(m) else { continue }
    let at = ["methods", i.to_string()]
    for j, param in m.params {
      if param is Inline(_) {
        roots.push([..at, "params", j.to_string(), "schema"])
      }
    }
    if m.result is Some(Inline(_)) {
      roots.push([..at, "result", "schema"])
    }
  }
  if document.components is Some(components) {
    if components.schemas() is Some(schemas) {
      for entry in schemas {
        roots.push(["components", "schemas", entry.0.to_string()])
      }
    }
    if components.content_descriptors() is Some(descriptors) {
      for entry in descriptors {
        roots.push([
          "components",
          "contentDescriptors",
          entry.0.to_string(),
          "schema",
        ])
      }
    }
  }
  roots
}

///|
/// The document as an explicit resource whose schema roots are every schema
/// position in its typed model. Other values stay opaque.
pub fn Document::as_resource(
  self : Document,
  uri : @reference.UriReference,
  aliases~ : Array[@reference.UriReference] = [],
  limits~ : @exact.Limits = @exact.Limits::new(),
) -> Result[@reference.Resource, @exact.Diagnostic] {
  let roots = []
  for tokens in document_schema_roots(self) {
    match @reference.JsonPointer::from_tokens(tokens, limits~) {
      Ok(pointer) => roots.push(pointer)
      Err(d) => return Err(d)
    }
  }
  @reference.Resource::new(
    uri,
    self.value,
    aliases~,
    schema_roots=roots,
    limits~,
  )
}

///|
/// Build the index for a document. A registered base holding exactly this
/// document makes it the physical resource; otherwise the document is the
/// unnamed input under the optional base.
fn document_index(
  document : Document,
  resources : @reference.ResourceSet,
  base : @reference.UriReference?,
  limits : @exact.Limits,
) -> (@reference.SchemaIndex, @reference.UriReference?) {
  let roots = document_schema_roots(document).map(tokens => {
    @reference.JsonPointer::from_tokens(
      tokens,
      limits=@exact.Limits::unlimited(),
    ).unwrap()
  })
  let registered = match base {
    Some(base) => resources.get(base)
    None => None
  }
  if registered is Some(resource) &&
    resource.value().equals(document.value, limits~) is Ok(true) {
    let declared = resource.schema_roots()
    let known : Set[String] = Set(declared.map(p => p.to_string()))
    for root in roots {
      if known.add_and_check(root.to_string()) {
        declared.push(root)
      }
    }
    let trusted = @exact.Limits::unlimited()
    let identity = resource.uri().identity()
    let replaced = @reference.Resource::new(
      resource.uri(),
      resource.value(),
      aliases=resource.aliases(),
      schema_roots=declared,
      limits=trusted,
    )
    let set = match replaced {
      Ok(replaced) =>
        @reference.ResourceSet::new(
          resources
          .resources()
          .map(r => if r.uri().identity() == identity { replaced } else { r }),
          root_base=resources.root_base(),
          limits=trusted,
        )
      Err(d) => Err(d)
    }
    // Re-registration only adds checked roots; keep the caller's set if not.
    let set = match set {
      Ok(set) => set
      Err(_) => resources
    }
    return (@reference.SchemaIndex::new(set, limits~), Some(resource.uri()))
  }
  // A different registered value at the base conflicts with the input, which
  // stays authoritative for its own content; validate reports the conflict.
  let resources = match registered {
    Some(resource) => {
      let identity = resource.uri().identity()
      match
        @reference.ResourceSet::new(
          resources.resources().filter(r => r.uri().identity() != identity),
          root_base=resources.root_base(),
          limits=@exact.Limits::unlimited(),
        ) {
        Ok(set) => set
        Err(_) => resources
      }
    }
    None => resources
  }
  (
    @reference.SchemaIndex::new(
      resources,
      input=Some(document.value),
      input_base=base,
      input_roots=roots,
      limits~,
    ),
    base,
  )
}

///|
fn coverage(findings : Array[Finding], phase : Phase) -> Coverage {
  if findings.any(f => {
      f.phase == phase &&
      f.severity == Error &&
      f.diagnostic.code() != @exact.LimitExceeded
    }) {
    Failed
  } else if findings.any(f => {
      f.phase == phase &&
      (
        f.diagnostic.code() == @exact.UnsupportedCheck ||
        f.diagnostic.code() == @exact.UnsupportedDialect ||
        f.diagnostic.code() == @exact.LimitExceeded ||
        unavailable(f.diagnostic.code())
      )
    }) {
    Incomplete
  } else {
    Passed
  }
}

///|
fn resource_key(resource : @reference.UriReference?) -> String {
  match resource {
    Some(uri) => uri.identity()
    None => ""
  }
}

///|
fn array_index(token : String) -> Bool {
  !token.is_empty() &&
  (token == "0" || !token.has_prefix("0")) &&
  token.code_units().iter().all(c => c >= 48 && c <= 57)
}

///|
/// Pointer tokens in document order: array indices numerically, other tokens
/// by UTF-16 code units, and a prefix before its extensions.
fn compare_paths(a : Array[String], b : Array[String]) -> Int {
  for i in 0..<(if a.length() < b.length() { a.length() } else { b.length() }) {
    let x = a[i]
    let y = b[i]
    let order = if array_index(x) && array_index(y) && x.length() != y.length() {
      x.length().compare(y.length())
    } else {
      x.lexical_compare(y)
    }
    if order != 0 {
      return order
    }
  }
  a.length().compare(b.length())
}

///|
fn compare_findings(a : Finding, b : Finding) -> Int {
  let order = resource_key(a.location.resource).lexical_compare(
    resource_key(b.location.resource),
  )
  if order != 0 {
    return order
  }
  let order = compare_paths(a.location.path, b.location.path)
  if order != 0 {
    return order
  }
  let order = a.diagnostic
    .code()
    .to_repr()
    .to_string()
    .lexical_compare(b.diagnostic.code().to_repr().to_string())
  if order != 0 {
    return order
  }
  let order = match (a.location.span, b.location.span) {
    (Some(x), Some(y)) => x.start().compare(y.start())
    (Some(_), None) => -1
    (None, Some(_)) => 1
    (None, None) => 0
  }
  if order != 0 {
    return order
  }
  a.diagnostic.message().lexical_compare(b.diagnostic.message())
}

///|
/// Turn drafts into findings: one per distinct problem, located in the input
/// source when known, ordered, and bounded by the finding limit. Truncated
/// phases get a `LimitExceeded` marker and become incomplete.
fn finish(
  drafts : Array[Draft],
  document : Document,
  owner : @reference.UriReference?,
  source_map : @exact.SourceMap?,
  profile : ValidationProfile,
  index_complete : Bool,
  limits : @exact.Limits,
) -> ValidationReport {
  let own = resource_key(owner)
  let locate = (site : Site) => {
    let mut span = None
    let mut source_id = None
    if resource_key(site.resource) == own && source_map is Some(map) {
      if map.locations(site.path) is [first, ..] {
        span = Some(first.value_span())
        source_id = first.source_id()
      }
    }
    FindingLocation::{ resource: site.resource, path: site.path, span, source_id }
  }
  let version = document.version
  let unique : Array[Finding] = []
  let seen : Set[String] = Set([])
  for d in drafts {
    let key = resource_key(d.site.resource) +
      "|" +
      d.phase.to_repr().to_string() +
      "|" +
      d.severity.to_repr().to_string() +
      "|" +
      d.diagnostic.code().to_repr().to_string() +
      "|" +
      site_key(None, d.site.path) +
      "|" +
      d.diagnostic.message()
    if !seen.add_and_check(key) {
      continue
    }
    let location = locate(d.site)
    unique.push({
      diagnostic: @exact.Diagnostic::new(
        d.diagnostic.code(),
        d.diagnostic.message(),
        path=d.site.path,
        span=location.span,
      ),
      severity: d.severity,
      phase: d.phase,
      location,
      related: d.related.map(locate),
      chain: d.chain.map(locate),
      expected: d.expected,
      discrepancy: d.discrepancy,
      version,
      profile,
    })
  }
  unique.sort_by(compare_findings)
  let marker = (phase, message) => Finding::{
    diagnostic: @exact.Diagnostic::new(@exact.LimitExceeded, message),
    severity: Information,
    phase,
    location: { resource: None, path: [], span: None, source_id: None },
    related: [],
    chain: [],
    expected: None,
    discrepancy: None,
    version,
    profile,
  }
  // A facet with a computed error stays failed even if truncation drops it.
  let failed = [Model, SchemaDocument, References, Normative, Artifact].filter(phase => {
    unique.any(f => {
      f.phase == phase &&
      f.severity == Error &&
      f.diagnostic.code() != @exact.LimitExceeded
    })
  })
  let max = limits.max_findings()
  let findings = if unique.length() > max {
    unique[:if max < 0 { 0 } else { max }].to_owned()
  } else {
    unique
  }
  let truncated : Array[Phase] = []
  for f in unique[findings.length():] {
    if !truncated.contains(f.phase) {
      truncated.push(f.phase)
    }
  }
  for phase in [Model, SchemaDocument, References, Normative, Artifact] {
    if truncated.contains(phase) {
      findings.push(
        marker(
          phase,
          "Finding limit max_findings=\{max} exceeded; later findings were omitted",
        ),
      )
    }
  }
  if !index_complete {
    findings.push(marker(References, "Reference index is incomplete"))
  }
  findings.sort_by(compare_findings)
  let facet = phase => if failed.contains(phase) {
    Failed
  } else {
    coverage(findings, phase)
  }
  {
    profile,
    version,
    model: facet(Model),
    schemas: facet(SchemaDocument),
    references: facet(References),
    normative: facet(Normative),
    artifact: facet(Artifact),
    findings,
  }
}

///|
/// Validate a document against explicit resources. `base` is the document's
/// retrieval URI; relative references and schema identifiers need it. Nothing
/// is fetched. Unsupplied resources leave reference coverage incomplete. A
/// `source_map` from reading the same document adds spans to its findings.
pub fn validate(
  document : Document,
  profile~ : ValidationProfile = Specification,
  resources~ : @reference.ResourceSet = @reference.ResourceSet::empty(),
  base~ : @reference.UriReference? = None,
  source_map~ : @exact.SourceMap? = None,
  limits~ : @exact.Limits = @exact.Limits::new(),
) -> ValidationReport {
  let findings : Array[Draft] = []
  let base = match base {
    Some(uri) if !uri.is_absolute() || uri.fragment() is Some(_) => {
      add(
        findings,
        @exact.InvalidReference,
        "Document base URI must be absolute and fragment-free",
        [],
        References,
      )
      None
    }
    _ => base
  }
  if base is Some(uri) &&
    resources.get(uri) is Some(registered) &&
    !(registered.value().equals(document.value, limits~) is Ok(true)) {
    add(
      findings,
      @exact.ConflictingIdentifier,
      "Base URI names a registered resource with different content; the input document is used",
      [],
      References,
    )
  }
  let (index, owner) = document_index(document, resources, base, limits)
  for d in index.diagnostics() {
    add(
      findings,
      d.code(),
      d.message(),
      d.path(),
      References,
      severity=reference_severity(d.code()),
    )
  }
  let check = Check::{
    index,
    findings,
    checked: Set([]),
    limits,
    version: document.version,
    profile,
    links: [],
  }
  let root = document.value
  check.fields(DocumentObject, root, owner, [])
  let info = document.info
  check.fields(InfoObject, info.value, owner, ["info"])
  if info.contact() is Some(contact) {
    check.fields(ContactObject, contact.value, owner, ["info", "contact"])
  }
  if info.license() is Some(license) {
    check.fields(LicenseObject, license.value, owner, ["info", "license"])
    check.named(
      license.name(),
      owner,
      ["info", "license"],
      "License",
      discrepancy=Some(LicenseNameOmitted),
    )
  }
  check.external_docs(document.external_docs(), owner, [])
  check.servers(document.servers(), owner, ["servers"])
  if root.get("$schema") is Some(value) && value.text() is None {
    add(
      findings,
      @exact.WrongType,
      "Root $schema must be a string",
      ["$schema"],
      Model,
      resource=owner,
    )
  }
  let raw_methods = match root.get("methods") {
    Some(raw) =>
      match raw.kind() {
        @exact.Array(items) => items
        _ => []
      }
    None => []
  }
  let method_decoder = (v, p) => {
    match method_(v, p, limits~) {
      Inline(m) => m
      Reference(_) =>
        raise DecodeError(
          @exact.Diagnostic::new(
            @exact.WrongType,
            "Expected an inline method",
            path=p,
          ),
        )
    }
  }
  let methods = []
  let errors = []
  let mut methods_complete = true
  for i, raw_method in raw_methods {
    guard check.typed(
        raw_method,
        owner,
        ["methods", i.to_string()],
        "method",
        method_decoder,
      )
      is Some(method_) else {
      methods_complete = false
      continue
    }
    methods.push(method_)
    let chain = method_.chain
    check.fields(
      MethodObject,
      method_.value.value,
      method_.owner,
      method_.path,
      chain~,
    )
    check.external_docs(
      method_.value.external_docs(),
      method_.owner,
      method_.path,
      chain~,
    )
    check.servers(
      method_.value.servers(),
      method_.owner,
      child(method_.path, "servers"),
      chain~,
    )
    let param_names : Map[String, Array[Site]] = Map([])
    let mut optional_seen = false
    let raw_params = match method_.value.value.get("params") {
      Some(raw) =>
        match raw.kind() {
          @exact.Array(items) => items
          _ => []
        }
      None => []
    }
    for pi, raw_param in raw_params {
      let param_path = child(child(method_.path, "params"), pi.to_string())
      guard check.typed(
          raw_param,
          method_.owner,
          param_path,
          "content descriptor",
          descriptor_decoder(limits),
          chain~,
        )
        is Some(desc) else {
        continue
      }
      check.fields(
        ContentDescriptorObject,
        desc.value.value,
        desc.owner,
        desc.path,
        chain=desc.chain,
      )
      let param_site = Site::{
        resource: desc.owner,
        path: child(desc.path, "name"),
      }
      match param_names.get(desc.value.name) {
        Some(first) =>
          check.normative(
            @exact.DuplicateName,
            "Parameter names must be unique",
            param_site,
            chain=desc.chain,
            related=first,
          )
        None => param_names[desc.value.name] = [param_site, ..desc.chain]
      }
      let required = desc.value.required.unwrap_or(false)
      if required && optional_seen {
        check.normative(
          @exact.ParameterOrder,
          "Optional parameters precede a required parameter",
          { resource: method_.owner, path: param_path },
          chain~,
          discrepancy=Some(OptionalParameterBeforeRequired),
        )
      }
      if !required {
        optional_seen = true
      }
      check.schema(desc.value.schema, desc.owner, child(desc.path, "schema"))
    }
    if method_.value.value.get("result") is Some(raw_result) {
      if check.typed(
          raw_result,
          method_.owner,
          child(method_.path, "result"),
          "content descriptor",
          descriptor_decoder(limits),
          chain~,
        )
        is Some(desc) {
        check.fields(
          ContentDescriptorObject,
          desc.value.value,
          desc.owner,
          desc.path,
          chain=desc.chain,
        )
        check.schema(desc.value.schema, desc.owner, child(desc.path, "schema"))
      }
    }
    let method_errors = check.items(method_, "errors", "error", (v, p) => {
      error_object_(v, p, limits)
    })
    for error in method_errors {
      check.fields(
        ErrorObjectKind,
        error.value.value,
        error.owner,
        error.path,
        chain=error.chain,
      )
    }
    errors.push(method_errors)
    for link in check.items(method_, "links", "link", (v, p) => {
      link_(v, p, limits)
    }) {
      check.link(link.value, link.owner, link.path, chain=link.chain)
    }
    for tag in check.items(method_, "tags", "tag", (v, p) => tag_(v, p, limits)) {
      check.tag(tag.value, tag.owner, tag.path, chain=tag.chain)
    }
    if method_.value.value.get("examples") is Some(raw) &&
      raw.kind() is @exact.Array(items) {
      for ei, item in items {
        check.pairing(
          item,
          method_.owner,
          child(child(method_.path, "examples"), ei.to_string()),
          chain~,
        )
      }
    }
  }
  if root.get("components") is Some(components) {
    match components.kind() {
      @exact.Object(_) => {
        check.fields(ComponentsObject, components, owner, ["components"])
        if components.get("schemas") is Some(schemas) {
          match schemas.kind() {
            @exact.Object(entries) =>
              for (name, value) in entries {
                let path = ["components", "schemas", name]
                match @schema.Schema::from_json(value, limits~) {
                  Ok(schema) => check.schema(schema, owner, path)
                  Err(d) =>
                    add(
                      findings,
                      d.code(),
                      d.message(),
                      [..path, ..d.path()],
                      SchemaDocument,
                      resource=owner,
                    )
                }
              }
            _ =>
              add(
                findings,
                @exact.WrongType,
                "Schema components must be an object",
                ["components", "schemas"],
                Model,
                resource=owner,
              )
          }
        }
        if document.components is Some(typed) {
          check.component_keys(typed, owner)
          let at = (table, key : ComponentKey) => {
            ["components", table, key.to_string()]
          }
          for entry in typed.content_descriptors().unwrap_or([]) {
            let path = at("contentDescriptors", entry.0)
            check.fields(ContentDescriptorObject, entry.1.value, owner, path)
            check.schema(entry.1.schema(), owner, child(path, "schema"))
          }
          for entry in typed.examples().unwrap_or([]) {
            check.example(entry.1.value, owner, at("examples", entry.0))
          }
          for entry in typed.links().unwrap_or([]) {
            check.link(entry.1, owner, at("links", entry.0))
          }
          for entry in typed.errors().unwrap_or([]) {
            check.fields(ErrorObjectKind, entry.1.value, owner, at("errors", entry.0))
          }
          for entry in typed.tags().unwrap_or([]) {
            check.tag(entry.1, owner, at("tags", entry.0))
          }
          for entry in typed.example_pairings().unwrap_or([]) {
            check.pairing(entry.1.value, owner, at("examplePairings", entry.0))
          }
          for entry in typed.example_pairing_objects().unwrap_or([]) {
            check.pairing(
              entry.1.value,
              owner,
              at("examplePairingObjects", entry.0),
            )
          }
        }
      }
      _ =>
        add(
          findings,
          @exact.WrongType,
          "Components must be an object",
          ["components"],
          Model,
          resource=owner,
        )
    }
  }
  check.uniqueness(methods, errors, methods_complete~)
  check.artifact(document, owner)
  finish(
    findings,
    document,
    owner,
    source_map,
    profile,
    index.complete(),
    limits,
  )
}

///|
pub fn validate_closed(
  document : Document,
  profile~ : ValidationProfile = Specification,
  resources~ : @reference.ResourceSet = @reference.ResourceSet::empty(),
  base~ : @reference.UriReference? = None,
  source_map~ : @exact.SourceMap? = None,
  limits~ : @exact.Limits = @exact.Limits::new(),
) -> ClosedValidation {
  let report = validate(
    document,
    profile~,
    resources~,
    base~,
    source_map~,
    limits~,
  )
  // Specification gates on normative rules and keeps artifact results as
  // information. Compatibility gates on the artifact; its normative facet
  // passes only when every error was a listed discrepancy (now a warning).
  let passed = report.model == Passed &&
    report.schemas == Passed &&
    report.references == Passed &&
    report.normative == Passed &&
    (profile == Specification || report.artifact == Passed)
  {
    report,
    document: if passed {
      Some({
        document,
        report,
        resources: resources.resources().map(r => r.uri()),
      })
    } else {
      None
    },
  }
}

///|
pub extend Coverage with Eq::{equal, not_equal}

///|
pub extend Coverage with @debug.Debug::{to_repr}

///|
pub extend ValidationProfile with Eq::{equal, not_equal}

///|
pub extend ValidationProfile with @debug.Debug::{to_repr}

///|
pub extend Severity with Eq::{equal, not_equal}

///|
pub extend Severity with @debug.Debug::{to_repr}

///|
pub extend Phase with Eq::{equal, not_equal}

///|
pub extend Phase with @debug.Debug::{to_repr}

///|
pub extend Finding with @debug.Debug::{to_repr}

///|
pub extend FindingLocation with @debug.Debug::{to_repr}

///|
pub extend ValidationReport with @debug.Debug::{to_repr}

///|
pub extend ValidatedDocument with @debug.Debug::{to_repr}

///|
pub extend ClosedValidation with @debug.Debug::{to_repr}