///|
fn metadata_error_message(error : MetadataError) -> (String, Int32?) {
  match error {
    InvalidYaml(line~, message~, ..) =>
      ("invalid frontmatter: \{message}", Some(line))
    Unterminated => ("the frontmatter block has no closing `---` line", Some(1))
  }
}

///|
/// Each document whose frontmatter block is invalid.
fn check_frontmatter_invalid(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.documents() {
    let (bundle, doc) = pair
    match doc.metadata_error() {
      Some(error) => {
        let (message, line) = metadata_error_message(error)
        out.push(
          Issue::at(
            bundle,
            doc,
            message,
            line?,
            hint="Fix the YAML between the `---` lines.",
          ),
        )
      }
      None => ()
    }
  }
  out
}

///|
/// Each concept without a frontmatter block, unless a profile derived its metadata.
fn check_concept_no_frontmatter(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.concepts() {
    let (bundle, doc) = pair
    if !doc.has_frontmatter_block() && !doc.is_derived() {
      out.push(
        Issue::at(
          bundle,
          doc,
          "the concept has no frontmatter",
          line=1,
          hint="Add a `---` block with at least `type`.",
        ),
      )
    }
  }
  out
}

///|
/// Each concept with valid metadata but no non-empty `type`.
fn check_concept_missing_type(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.concepts() {
    let (bundle, doc) = pair
    let has_metadata = doc.written_metadata() is Some(_) || doc.is_derived()
    if has_metadata && doc.concept_type() is None {
      out.push(
        Issue::at(
          bundle,
          doc,
          "the concept has no `type`",
          line=doc.metadata().line_of("type").unwrap_or(1),
        ),
      )
    }
  }
  out
}

///|
/// Each sub-index with a frontmatter block.
fn check_subindex_frontmatter(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.documents() {
    let (bundle, doc) = pair
    if doc.kind() is SubIndex && doc.has_frontmatter_block() {
      out.push(
        Issue::at(
          bundle,
          doc,
          "a sub-index has frontmatter",
          line=1,
          hint="Only the root index can carry `okf_version`.",
        ),
      )
    }
  }
  out
}

///|
/// Each root index frontmatter key other than `okf_version` and profile bundle keys.
fn check_root_index_keys(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for bundle in ctx.knowledge.bundles() {
    match bundle.root_index() {
      Some(doc) =>
        match doc.written_metadata() {
          Some(meta) =>
            for key in meta.keys() {
              if !ctx.vocabulary.knows_bundle_key(key) {
                out.push(
                  Issue::at(
                    bundle,
                    doc,
                    "the root index has the key `\{key}`",
                    line=meta.line_of(key).unwrap_or(1),
                  ),
                )
              }
            }
          None => ()
        }
      None => ()
    }
  }
  out
}

///|
/// Each log section whose heading is not a `YYYY-MM-DD` date.
fn check_log_undated(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.documents() {
    let (bundle, doc) = pair
    if doc.kind() is Log {
      for section in doc.log_model().undated_sections() {
        out.push(
          Issue::at(
            bundle,
            doc,
            "the log section `\{section.heading}` is not a `YYYY-MM-DD` date",
            line=section.line,
          ),
        )
      }
    }
  }
  out
}

///|
/// The OKF v0.2 conformance rules (section 11): parseable frontmatter and a
/// non-empty `type` on each concept, and `index.md` and `log.md` in their formats.
pub fn okf_v02_conformance() -> RuleSet {
  RuleSet("okf-v0.2", [
    Rule(builtin_rule("frontmatter-invalid"), Error, check_frontmatter_invalid),
    Rule(
      builtin_rule("concept-no-frontmatter"),
      Error,
      check_concept_no_frontmatter,
    ),
    Rule(
      builtin_rule("concept-missing-type"),
      Error,
      check_concept_missing_type,
    ),
    Rule(
      builtin_rule("subindex-has-frontmatter"),
      Error,
      check_subindex_frontmatter,
    ),
    Rule(builtin_rule("log-undated-section"), Error, check_log_undated),
  ])
}

///|
fn concept_rule(
  check : (ValidationContext, Bundle, Document) -> Issue?,
) -> (ValidationContext) -> Array[Issue] {
  ctx => {
    let out = []
    for pair in ctx.knowledge.concepts() {
      match check(ctx, pair.0, pair.1) {
        Some(issue) => out.push(issue)
        None => ()
      }
    }
    out
  }
}

///|
fn has_metadata(doc : Document) -> Bool {
  doc.written_metadata() is Some(_) || doc.is_derived()
}

///|
fn check_missing_title(
  _ctx : ValidationContext,
  bundle : Bundle,
  doc : Document,
) -> Issue? {
  if has_metadata(doc) && doc.metadata().title() is None {
    Some(Issue::at(bundle, doc, "the concept has no `title`", line=1))
  } else {
    None
  }
}

///|
fn check_missing_description(
  _ctx : ValidationContext,
  bundle : Bundle,
  doc : Document,
) -> Issue? {
  if has_metadata(doc) && doc.metadata().description() is None {
    Some(Issue::at(bundle, doc, "the concept has no `description`", line=1))
  } else {
    None
  }
}

///|
fn check_status_unknown(
  _ctx : ValidationContext,
  bundle : Bundle,
  doc : Document,
) -> Issue? {
  match doc.metadata().status() {
    Some(Unknown(text)) =>
      Some(
        Issue::at(
          bundle,
          doc,
          "`status: \{text}` is not draft, stable or deprecated",
          line=doc.metadata().line_of("status").unwrap_or(1),
        ),
      )
    _ => None
  }
}

///|
fn check_stale(
  ctx : ValidationContext,
  bundle : Bundle,
  doc : Document,
) -> Issue? {
  let meta = doc.metadata()
  if meta.is_stale(ctx.now, options=bundle.options()) {
    let limit = meta.stale_after().unwrap_or("")
    Some(
      Issue::at(
        bundle,
        doc,
        "`stale_after: \{limit}` has passed",
        line=meta.line_of("stale_after").unwrap_or(1),
      ),
    )
  } else {
    None
  }
}

///|
fn check_unknown_keys(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.concepts() {
    let (bundle, doc) = pair
    match doc.written_metadata() {
      Some(meta) =>
        for key in meta.keys() {
          if !ctx.vocabulary.knows(key) {
            out.push(
              Issue::at(
                bundle,
                doc,
                "the key `\{key}` is not in the OKF or profile vocabulary",
                line=meta.line_of(key).unwrap_or(1),
              ),
            )
          }
        }
      None => ()
    }
  }
  out
}

///|
fn check_not_indexed(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for bundle in ctx.knowledge.bundles() {
    if bundle.indexes().is_empty() {
      continue
    }
    for doc in bundle.unindexed_concepts() {
      out.push(
        Issue::at(
          bundle,
          doc,
          "no index of the bundle links to this concept",
          hint="Add an entry to the nearest `index.md`.",
        ),
      )
    }
  }
  out
}

///|
fn check_drift(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for bundle in ctx.knowledge.bundles() {
    for drift in bundle.description_drift() {
      out.push(
        Issue::at(
          bundle,
          drift.index,
          "the entry for \{drift.concept.path()} does not match its description",
          line=drift.entry.line,
          hint="Use: \{drift.expected}",
        ),
      )
    }
  }
  out
}

///|
fn check_duplicate_titles(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for bundle in ctx.knowledge.bundles() {
    let seen : Map[String, Document] = Map([])
    for doc in bundle.concepts() {
      guard doc.metadata().title() is Some(title) else { continue }
      let key = title.to_lower()
      match seen.get(key) {
        Some(first) =>
          out.push(
            Issue::at(
              bundle,
              doc,
              "the title `\{title}` is also used by \{first.path()}",
            ),
          )
        None => seen[key] = doc
      }
    }
  }
  out
}

///|
fn check_broken_links(ctx : ValidationContext) -> Array[Issue] {
  ctx.graph
  .broken()
  .map(edge => {
    Issue::in_bundle(
      edge.from,
      "the link to `\{edge.link.destination}` does not resolve",
      line?=edge.link.line_number(),
    )
  })
}

///|
fn check_escaping_links(ctx : ValidationContext) -> Array[Issue] {
  ctx.graph
  .edges()
  .iter()
  .filter(e => e.escapes && e.link.kind is BundleRooted)
  .map(edge => {
    Issue::in_bundle(
      edge.from,
      "the link to `\{edge.link.destination}` leaves the bundle",
      line?=edge.link.line_number(),
    )
  })
  .collect()
}

///|
fn check_strays(ctx : ValidationContext) -> Array[Issue] {
  ctx.knowledge
  .strays()
  .iter()
  .map(path => Issue::stray(path, "the Markdown file belongs to no bundle"))
  .collect()
}

///|
fn check_log_order(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.documents() {
    let (bundle, doc) = pair
    if doc.kind() is Log {
      match doc.log_model().order_problem() {
        Some((a, b)) =>
          out.push(
            Issue::at(
              bundle,
              doc,
              "`\{a.heading}` comes before the newer `\{b.heading}`",
              line=b.line,
            ),
          )
        None => ()
      }
    }
  }
  out
}

///|
fn check_unknown_version(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for bundle in ctx.knowledge.bundles() {
    if !bundle.knows_version() {
      match bundle.root_index() {
        Some(doc) => {
          let version = bundle
            .okf_version()
            .map(v => v.to_string())
            .unwrap_or("")
          out.push(
            Issue::at(
              bundle,
              doc,
              "OKF version \{version} is not known; read with best effort",
            ),
          )
        }
        None => ()
      }
    }
  }
  out
}

///|
fn check_ignored_logs(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for bundle in ctx.knowledge.bundles() {
    for path in bundle.ignored_logs() {
      out.push(
        Issue::in_bundle(
          { bundle: bundle.label(), path, },
          "the nested log is ignored",
        ),
      )
    }
  }
  out
}

///|
fn check_unresolved_references(ctx : ValidationContext) -> Array[Issue] {
  let out = []
  for pair in ctx.knowledge.documents() {
    let (bundle, doc) = pair
    for r in typed_references(ctx.knowledge, bundle, doc, profiles=ctx.profiles) {
      if !r.is_resolved() {
        out.push(
          Issue::at(
            bundle,
            doc,
            "`\{r.key}: \{r.value}` does not resolve",
            line?=r.line,
          ),
        )
      }
    }
  }
  out
}

///|
/// Advisory rules. They report quality problems that OKF v0.2 tolerates; the
/// caller sets their severity with `ValidationConfig`.
pub fn advisory() -> RuleSet {
  RuleSet("okf-advisory", [
    Rule(
      builtin_rule("concept-missing-title"),
      Warning,
      concept_rule(check_missing_title),
    ),
    Rule(
      builtin_rule("concept-missing-description"),
      Warning,
      concept_rule(check_missing_description),
    ),
    Rule(
      builtin_rule("status-unknown"),
      Warning,
      concept_rule(check_status_unknown),
    ),
    Rule(builtin_rule("stale-after-passed"), Warning, concept_rule(check_stale)),
    Rule(builtin_rule("frontmatter-unknown-key"), Info, check_unknown_keys),
    Rule(builtin_rule("index-frontmatter-key"), Warning, check_root_index_keys),
    Rule(builtin_rule("concept-not-indexed"), Warning, check_not_indexed),
    Rule(builtin_rule("index-description-drift"), Warning, check_drift),
    Rule(builtin_rule("duplicate-title"), Warning, check_duplicate_titles),
    Rule(builtin_rule("link-broken"), Warning, check_broken_links),
    Rule(builtin_rule("link-escapes-bundle"), Warning, check_escaping_links),
    Rule(
      builtin_rule("reference-unresolved"),
      Warning,
      check_unresolved_references,
    ),
    Rule(builtin_rule("stray-markdown"), Warning, check_strays),
    Rule(builtin_rule("log-not-newest-first"), Warning, check_log_order),
    Rule(builtin_rule("okf-version-unknown"), Info, check_unknown_version),
    Rule(builtin_rule("log-ignored"), Info, check_ignored_logs),
  ])
}