// audit.mbt — Deterministic, offline audit of a link collection.
//
// Audit reviews a parsed `LinkSet` for suspicious or deprecated patterns. It
// is advisory: a link set with issues is still well-formed and usable. Every
// check is a pure function of the model:
//
//   - the registry membership check uses the offline IANA snapshot
//     (relation_registry.mbt) — audit never performs a network request,
//   - the checks run in a fixed order and links are walked in document
//     order, so `audit_link_set` is deterministic: the same input always
//     produces the same report.
//
// Audit catches the patterns RFC 8288 warns about: the deprecated `rev`
// parameter, relation types that look registered but are not in the registry
// (typos are the classic case), the coexistence of `title` and `title*`,
// redundant or relative anchors, values beyond the configured resource
// limits, and duplicate parameter names or duplicate links.
//
// The parser itself enforces RFC 8288's first-wins rule for the
// single-occurrence target attributes, so a model never contains two
// `anchor` / `media` / `title` / `type` values; duplicates of *extension*
// parameters are preserved in `WebLink::extensions` and are reported here.

///|
/// The importance of an audit finding.
pub enum AuditSeverity {
  Info
  Warning
  Error
} derive(Eq)

///|
/// A single audit finding. `code` is a stable, machine-readable identifier;
/// `message` is human readable; `link_index` is the 0-based index of the
/// offending link, or `-1` for a set-level finding.
pub struct AuditIssue {
  severity : AuditSeverity
  code : String
  message : String
  link_index : Int
}

///|
/// The result of auditing a link set: every finding, in deterministic order.
pub struct AuditReport {
  issues : Array[AuditIssue]
}

///|
/// The stable lowercase name of a severity, used by the CLI JSON output.
pub fn AuditSeverity::to_string(self : AuditSeverity) -> String {
  match self {
    Info => "info"
    Warning => "warning"
    Error => "error"
  }
}

///|
/// The severity of this finding.
pub fn AuditIssue::severity(self : AuditIssue) -> AuditSeverity {
  self.severity
}

///|
/// The stable machine-readable code of this finding.
pub fn AuditIssue::code(self : AuditIssue) -> String {
  self.code
}

///|
/// A human readable description of this finding.
pub fn AuditIssue::message(self : AuditIssue) -> String {
  self.message
}

///|
/// The 0-based index of the offending link, or `-1` for set-level findings.
pub fn AuditIssue::link_index(self : AuditIssue) -> Int {
  self.link_index
}

///|
/// The findings, in deterministic order.
pub fn AuditReport::issues(self : AuditReport) -> Array[AuditIssue] {
  self.issues
}

///|
/// The number of findings.
pub fn AuditReport::issue_count(self : AuditReport) -> Int {
  self.issues.length()
}

///|
/// Whether the report contains no findings.
pub fn AuditReport::is_clean(self : AuditReport) -> Bool {
  self.issues.is_empty()
}

///|
/// The number of findings at the given severity.
pub fn AuditReport::count_at(
  self : AuditReport,
  severity : AuditSeverity,
) -> Int {
  let mut n = 0
  for issue in self.issues {
    if issue.severity == severity {
      n = n + 1
    }
  }
  n
}

///|
/// Whether the report contains at least one `Error`-severity finding.
pub fn AuditReport::has_errors(self : AuditReport) -> Bool {
  self.issues.any(fn(i) { i.severity == Error })
}

///|
/// Audits a link set against the default resource limits.
pub fn audit_link_set(linkset : LinkSet) -> AuditReport {
  audit_link_set_with_limits(linkset, Limits::default())
}

///|
/// Audits a link set against the given resource limits. The parse-time
/// `max_links` and `max_input_bytes` bounds do not apply here (the model is
/// already in memory); the per-value bounds are still enforced.
pub fn audit_link_set_with_limits(
  linkset : LinkSet,
  limits : Limits,
) -> AuditReport {
  audit_links(linkset.links(), limits)
}

///|
fn audit_links(links : Array[WebLink], limits : Limits) -> AuditReport {
  let issues = Array::new()

  // Set-level: duplicate links (same target and same relation set).
  // The quadratic scan is bounded to keep worst-case audit cost linear for
  // very large link sets.
  if links.length() <= 512 {
    for i = 0; i < links.length(); i = i + 1 {
      for j = i + 1; j < links.length(); j = j + 1 {
        if links_equivalent(links[i], links[j]) {
          push_issue(
            issues,
            Warning,
            "duplicate-link",
            "duplicate link: same target and relation set as link \{i}",
            j,
          )
          break
        }
      }
    }
  }

  for i = 0; i < links.length(); i = i + 1 {
    audit_link(issues, links[i], i, limits)
  }

  { issues, }
}

///|
/// Parses `input` as a `Link` header with the default limits and audits the
/// result. Any parse error is returned unchanged.
pub fn audit_link_header(input : String) -> Result[AuditReport, LinkError] {
  audit_link_header_with_limits(input, Limits::default())
}

///|
/// Parses `input` as a `Link` header with the given limits and audits the
/// result. Any parse error is returned unchanged.
pub fn audit_link_header_with_limits(
  input : String,
  limits : Limits,
) -> Result[AuditReport, LinkError] {
  match parse_link_header_detailed(input, limits) {
    Ok(parsed) => {
      let base = audit_links(parsed.links(), limits)
      Ok(add_duplicate_singletons(base, parsed.duplicates()))
    }
    Err(e) => Err(e)
  }
}

///|
/// Adds a set-level finding for every single-occurrence parameter that the
/// parser had to ignore (RFC 8288 first-wins).
fn add_duplicate_singletons(
  base : AuditReport,
  duplicates : Array[String],
) -> AuditReport {
  if duplicates.is_empty() {
    return base
  }
  let issues = Array::new()
  for issue in base.issues() {
    issues.push(issue)
  }
  for name in duplicates {
    push_issue(
      issues,
      Info,
      "duplicate-singleton",
      "single-occurrence parameter \"\{name}\" appeared more than once; later values were ignored",
      -1,
    )
  }
  { issues, }
}

///|
fn push_issue(
  issues : Array[AuditIssue],
  severity : AuditSeverity,
  code : String,
  message : String,
  link_index : Int,
) -> Unit {
  issues.push({ severity, code, message, link_index })
}

///|
fn audit_link(
  issues : Array[AuditIssue],
  link : WebLink,
  index : Int,
  limits : Limits,
) -> Unit {
  // 1. Deprecated `rev` parameter (RFC 8288 Section 3.4.1). The parser
  //    preserves it as an extension parameter; flag it here.
  for p in link.extensions() {
    if p.name().equal_ignore_ascii_case("rev") {
      push_issue(
        issues,
        Warning,
        "deprecated-rev",
        "\"rev\" is deprecated by RFC 8288; use a registered relation type",
        index,
      )
    }
  }

  // 2. Relation types that look registered (token form) but are not in the
  //    offline IANA snapshot. A typo is the common cause.
  for rt in link.relations() {
    if rt.is_registered() {
      let name = match rt {
        Registered(n) => n
        Extension(_) => abort("unreachable: is_registered is false")
      }
      if !is_registered_relation(name) {
        push_issue(
          issues,
          Warning,
          "unregistered-relation",
          "relation type \"\{name}\" is not in the IANA registry",
          index,
        )
      }
    }
  }

  // 3. Coexistence of `title` and `title*` (RFC 8288 Section 3.4.2).
  //    When both are present applications SHOULD use the `title*` value.
  match link.title() {
    Some(_) =>
      match link.title_star() {
        Some(_) =>
          push_issue(
            issues,
            Info,
            "title-and-title-star",
            "both title and title* are present; title* should be preferred",
            index,
          )
        None => ()
      }
    None => ()
  }

  // 4. Anchor checks (RFC 8288 Section 3.2): an anchor equal to the target
  //    is redundant, and a relative anchor leaves the context ambiguous.
  match link.anchor() {
    Some(a) =>
      if a == link.target() {
        push_issue(
          issues,
          Info,
          "redundant-anchor",
          "anchor equals the link target",
          index,
        )
      } else if !is_absolute_uri_reference(a) {
        push_issue(
          issues,
          Warning,
          "relative-anchor",
          "anchor \"\{a}\" is a relative reference; the context is ambiguous",
          index,
        )
      }
    None => ()
  }

  // 5. Oversized values relative to the configured limits.
  if byte_len(link.target()) > limits.max_target_bytes() {
    push_issue(
      issues,
      Warning,
      "oversized-target",
      "target exceeds max_target_bytes (\{limits.max_target_bytes()})",
      index,
    )
  }
  match link.title() {
    Some(t) =>
      if byte_len(t) > limits.max_quoted_string_bytes() {
        push_issue(
          issues,
          Warning,
          "oversized-title",
          "title exceeds max_quoted_string_bytes (\{limits.max_quoted_string_bytes()})",
          index,
        )
      }
    None => ()
  }
  match link.title_star() {
    Some(ev) =>
      if byte_len(ev.value()) > limits.max_quoted_string_bytes() {
        push_issue(
          issues,
          Warning,
          "oversized-title",
          "title* value exceeds max_quoted_string_bytes (\{limits.max_quoted_string_bytes()})",
          index,
        )
      }
    None => ()
  }
  for p in link.extensions() {
    if byte_len(p.name()) > limits.max_parameter_name_bytes() {
      push_issue(
        issues,
        Warning,
        "oversized-parameter",
        "parameter name exceeds max_parameter_name_bytes (\{limits.max_parameter_name_bytes()})",
        index,
      )
    }
    match p.value() {
      Some(v) =>
        if byte_len(v) > limits.max_parameter_value_bytes() {
          push_issue(
            issues,
            Warning,
            "oversized-parameter",
            "parameter value exceeds max_parameter_value_bytes (\{limits.max_parameter_value_bytes()})",
            index,
          )
        }
      None => ()
    }
  }

  // 6. Duplicate extension parameter names (extension params are repeatable,
  //    so this is legal — but a repeated name is usually a sender bug).
  for k = 0; k < link.extensions().length(); k = k + 1 {
    let name = link.extensions()[k].name()
    let mut earlier = false
    for m = 0; m < k; m = m + 1 {
      if link.extensions()[m].name().equal_ignore_ascii_case(name) {
        earlier = true
        break
      }
    }
    if earlier {
      push_issue(
        issues,
        Info,
        "duplicate-extension-parameter",
        "extension parameter \"\{name}\" appears more than once",
        index,
      )
    }
  }
}

///|
fn links_equivalent(a : WebLink, b : WebLink) -> Bool {
  if a.target() != b.target() {
    return false
  }
  let an = a.relation_names()
  let bn = b.relation_names()
  if an.length() != bn.length() {
    return false
  }
  for i = 0; i < an.length(); i = i + 1 {
    if an[i] != bn[i] {
      return false
    }
  }
  true
}

///|
/// Byte length of a string (UTF-8 encoding). Used by the limit checks.
fn byte_len(s : String) -> Int {
  @utf8.encode(s).length()
}

///|
/// Whether `s` is an absolute URI reference, i.e. starts with a valid
/// RFC 3986 scheme. Used to distinguish absolute anchors from relative ones.
fn is_absolute_uri_reference(s : String) -> Bool {
  let bytes = @utf8.encode(s)
  let n = bytes.length()
  if n == 0 {
    return false
  }
  if !is_alpha(bytes[0]) {
    return false
  }
  for i = 1; i < n; i = i + 1 {
    let b = bytes[i]
    if b == 58 {
      return true
    }
    if is_alnum(b) {
      continue
    }
    if b == 43 || b == 45 || b == 46 {
      continue
    }
    return false
  }
  false
}

///|
fn is_alnum(b : Byte) -> Bool {
  is_alpha(b) || (b.to_int() >= 48 && b.to_int() <= 57)
}