///|
pub(all) enum RulePatternKind {
  PatternEmpty
  PatternRoot
  PatternExactPath
  PatternDirectoryPrefix
  PatternFilePath
  PatternExtensionGlob
  PatternWildcardPrefix
  PatternWildcardSuffix
  PatternWildcardMiddle
  PatternAnchored
  PatternCatchAll
} derive(Eq, Debug)

///|
pub(all) enum RuleImpact {
  ImpactAllowEmpty
  ImpactAllowNarrow
  ImpactAllowWide
  ImpactBlockRoot
  ImpactBlockDirectory
  ImpactBlockFile
  ImpactBlockWildcard
  ImpactNeutral
} derive(Eq, Debug)

///|
pub(all) enum RuleRiskBand {
  RuleRiskLow
  RuleRiskMedium
  RuleRiskHigh
  RuleRiskCritical
} derive(Eq, Debug)

///|
pub(all) enum RuleRelation {
  RelationDuplicate
  RelationBroader
  RelationNarrower
  RelationConflict
  RelationIndependent
} derive(Eq, Debug)

///|
pub(all) struct RuleProfile {
  rule : Rule
  kind : RulePatternKind
  impact : RuleImpact
  risk : RuleRiskBand
  normalized_pattern : String
  literal_weight : Int
  depth : Int
  wildcard_count : Int
  has_anchor : Bool
  has_query_like_text : Bool
  has_fragment_like_text : Bool
  is_root_rule : Bool
  is_empty_pattern : Bool
  is_directory_rule : Bool
  is_file_rule : Bool
  is_wildcard_rule : Bool
  prefix : String
  extension : String?
  explanation : String
  notes : Array[String]
} derive(Eq, Debug)

///|
pub(all) struct GroupRuleSummary {
  agents : Array[String]
  group_line : Int
  rule_count : Int
  allow_count : Int
  disallow_count : Int
  wildcard_rule_count : Int
  anchored_rule_count : Int
  root_block_count : Int
  empty_rule_count : Int
  max_depth : Int
  max_literal_weight : Int
  crawl_delay : Int?
  has_sitemap_hint : Bool
  notes : Array[String]
} derive(Eq, Debug)

///|
pub(all) struct PolicyProfile {
  group_summaries : Array[GroupRuleSummary]
  total_rules : Int
  total_allows : Int
  total_disallows : Int
  total_wildcards : Int
  total_anchors : Int
  total_root_blocks : Int
  highest_depth : Int
  highest_weight : Int
  has_wildcard_group : Bool
  has_specific_group : Bool
  has_sitemap : Bool
  issue_count : Int
  notes : Array[String]
} derive(Eq, Debug)

///|
pub(all) struct RulePairAnalysis {
  first : RuleProfile
  second : RuleProfile
  relation : RuleRelation
  message : String
  same_action : Bool
  same_pattern : Bool
  first_matches_second_prefix : Bool
  second_matches_first_prefix : Bool
} derive(Eq, Debug)

///|
pub fn RulePatternKind::label(self : RulePatternKind) -> String {
  match self {
    PatternEmpty => "empty"
    PatternRoot => "root"
    PatternExactPath => "exact-path"
    PatternDirectoryPrefix => "directory-prefix"
    PatternFilePath => "file-path"
    PatternExtensionGlob => "extension-glob"
    PatternWildcardPrefix => "wildcard-prefix"
    PatternWildcardSuffix => "wildcard-suffix"
    PatternWildcardMiddle => "wildcard-middle"
    PatternAnchored => "anchored"
    PatternCatchAll => "catch-all"
  }
}

///|
pub fn RuleImpact::label(self : RuleImpact) -> String {
  match self {
    ImpactAllowEmpty => "allow-empty"
    ImpactAllowNarrow => "allow-narrow"
    ImpactAllowWide => "allow-wide"
    ImpactBlockRoot => "block-root"
    ImpactBlockDirectory => "block-directory"
    ImpactBlockFile => "block-file"
    ImpactBlockWildcard => "block-wildcard"
    ImpactNeutral => "neutral"
  }
}

///|
pub fn RuleRiskBand::label(self : RuleRiskBand) -> String {
  match self {
    RuleRiskLow => "low"
    RuleRiskMedium => "medium"
    RuleRiskHigh => "high"
    RuleRiskCritical => "critical"
  }
}

///|
pub fn RuleRelation::label(self : RuleRelation) -> String {
  match self {
    RelationDuplicate => "duplicate"
    RelationBroader => "broader"
    RelationNarrower => "narrower"
    RelationConflict => "conflict"
    RelationIndependent => "independent"
  }
}

///|
pub fn analyze_rule(rule : Rule) -> RuleProfile {
  let normalized_pattern = normalize_rule_pattern(rule.pattern)
  let literal_weight = pattern_weight(normalized_pattern)
  let depth = rule_pattern_depth(normalized_pattern)
  let wildcard_count = count_char(normalized_pattern, '*')
  let has_anchor = normalized_pattern.has_suffix("$")
  let is_empty_pattern = normalized_pattern.is_empty()
  let is_root_rule = normalized_pattern == "/"
  let is_directory_rule = looks_like_directory_rule(normalized_pattern)
  let is_file_rule = looks_like_file_rule(normalized_pattern)
  let is_wildcard_rule = wildcard_count > 0
  let has_query_like_text = normalized_pattern.contains("?")
  let has_fragment_like_text = normalized_pattern.contains("#")
  let kind = classify_rule_pattern(
    normalized_pattern, is_empty_pattern, is_root_rule, is_directory_rule, is_file_rule,
    is_wildcard_rule, has_anchor,
  )
  let impact = classify_rule_impact(
    rule.allow,
    kind,
    is_root_rule,
    is_directory_rule,
    is_file_rule,
    is_wildcard_rule,
  )
  let risk = classify_rule_risk(
    rule.allow,
    kind,
    impact,
    wildcard_count,
    literal_weight,
    has_query_like_text,
  )
  let prefix = rule_prefix(normalized_pattern)
  let extension = rule_extension(normalized_pattern)
  let notes = rule_notes(
    rule, normalized_pattern, kind, impact, risk, wildcard_count, has_query_like_text,
    has_fragment_like_text,
  )
  {
    rule,
    kind,
    impact,
    risk,
    normalized_pattern,
    literal_weight,
    depth,
    wildcard_count,
    has_anchor,
    has_query_like_text,
    has_fragment_like_text,
    is_root_rule,
    is_empty_pattern,
    is_directory_rule,
    is_file_rule,
    is_wildcard_rule,
    prefix,
    extension,
    explanation: rule_explanation(rule, kind, impact, risk),
    notes,
  }
}

///|
pub fn analyze_rules(rules : Array[Rule]) -> Array[RuleProfile] {
  let profiles : Array[RuleProfile] = []
  for rule in rules {
    profiles.push(analyze_rule(rule))
  }
  profiles
}

///|
pub fn AgentGroup::profiles(self : AgentGroup) -> Array[RuleProfile] {
  analyze_rules(self.rules)
}

///|
pub fn AgentGroup::summary(
  self : AgentGroup,
  has_sitemap_hint : Bool,
) -> GroupRuleSummary {
  summarize_group(self, has_sitemap_hint)
}

///|
pub fn RobotsPolicy::profile(self : RobotsPolicy) -> PolicyProfile {
  profile_policy(self)
}

///|
pub fn profile_policy(policy : RobotsPolicy) -> PolicyProfile {
  let summaries : Array[GroupRuleSummary] = []
  let notes : Array[String] = []
  let mut total_rules = 0
  let mut total_allows = 0
  let mut total_disallows = 0
  let mut total_wildcards = 0
  let mut total_anchors = 0
  let mut total_root_blocks = 0
  let mut highest_depth = 0
  let mut highest_weight = 0
  let mut has_wildcard_group = false
  let mut has_specific_group = false
  for group in policy.groups {
    let summary = summarize_group(group, !policy.sitemaps.is_empty())
    total_rules = total_rules + summary.rule_count
    total_allows = total_allows + summary.allow_count
    total_disallows = total_disallows + summary.disallow_count
    total_wildcards = total_wildcards + summary.wildcard_rule_count
    total_anchors = total_anchors + summary.anchored_rule_count
    total_root_blocks = total_root_blocks + summary.root_block_count
    if summary.max_depth > highest_depth {
      highest_depth = summary.max_depth
    }
    if summary.max_literal_weight > highest_weight {
      highest_weight = summary.max_literal_weight
    }
    if group.agents.contains("*") {
      has_wildcard_group = true
    } else {
      has_specific_group = true
    }
    summaries.push(summary)
  }
  if policy.groups.is_empty() {
    notes.push("no user-agent group is defined")
  }
  if policy.sitemaps.is_empty() {
    notes.push("no sitemap directive is present")
  }
  if total_root_blocks > 0 {
    notes.push("at least one group blocks the root path")
  }
  if total_wildcards > 0 {
    notes.push("wildcard matching is used")
  }
  if policy.issues.length() > 0 {
    notes.push("parse issues should be reviewed")
  }
  {
    group_summaries: summaries,
    total_rules,
    total_allows,
    total_disallows,
    total_wildcards,
    total_anchors,
    total_root_blocks,
    highest_depth,
    highest_weight,
    has_wildcard_group,
    has_specific_group,
    has_sitemap: !policy.sitemaps.is_empty(),
    issue_count: policy.issues.length(),
    notes,
  }
}

///|
pub fn summarize_group(
  group : AgentGroup,
  has_sitemap_hint : Bool,
) -> GroupRuleSummary {
  let profiles = analyze_rules(group.rules)
  let notes : Array[String] = []
  let mut allow_count = 0
  let mut disallow_count = 0
  let mut wildcard_rule_count = 0
  let mut anchored_rule_count = 0
  let mut root_block_count = 0
  let mut empty_rule_count = 0
  let mut max_depth = 0
  let mut max_literal_weight = 0
  for profile in profiles {
    if profile.rule.allow {
      allow_count = allow_count + 1
    } else {
      disallow_count = disallow_count + 1
    }
    if profile.is_wildcard_rule {
      wildcard_rule_count = wildcard_rule_count + 1
    }
    if profile.has_anchor {
      anchored_rule_count = anchored_rule_count + 1
    }
    if profile.is_root_rule && !profile.rule.allow {
      root_block_count = root_block_count + 1
    }
    if profile.is_empty_pattern {
      empty_rule_count = empty_rule_count + 1
    }
    if profile.depth > max_depth {
      max_depth = profile.depth
    }
    if profile.literal_weight > max_literal_weight {
      max_literal_weight = profile.literal_weight
    }
  }
  if group.rules.is_empty() {
    notes.push("group has no effective allow/disallow rules")
  }
  if allow_count > 0 && disallow_count == 0 {
    notes.push("group only contains allow rules")
  }
  if disallow_count > 0 && allow_count == 0 {
    notes.push("group only contains disallow rules")
  }
  if root_block_count > 0 {
    notes.push(
      "group blocks the whole site unless later allow rules carve it back",
    )
  }
  if wildcard_rule_count > 0 {
    notes.push("group uses wildcard rules")
  }
  if group.crawl_delay is Some(delay) && delay > 60 {
    notes.push("crawl-delay is high")
  }
  {
    agents: group.agents,
    group_line: group.line,
    rule_count: group.rules.length(),
    allow_count,
    disallow_count,
    wildcard_rule_count,
    anchored_rule_count,
    root_block_count,
    empty_rule_count,
    max_depth,
    max_literal_weight,
    crawl_delay: group.crawl_delay,
    has_sitemap_hint,
    notes,
  }
}

///|
pub fn compare_rules(first : Rule, second : Rule) -> RulePairAnalysis {
  compare_rule_profiles(analyze_rule(first), analyze_rule(second))
}

///|
pub fn compare_rule_profiles(
  first : RuleProfile,
  second : RuleProfile,
) -> RulePairAnalysis {
  let same_action = first.rule.allow == second.rule.allow
  let same_pattern = first.normalized_pattern == second.normalized_pattern
  let first_matches_second_prefix = prefix_covers(
    first.prefix,
    second.normalized_pattern,
  )
  let second_matches_first_prefix = prefix_covers(
    second.prefix,
    first.normalized_pattern,
  )
  let relation = if same_pattern && same_action {
    RelationDuplicate
  } else if same_pattern && !same_action {
    RelationConflict
  } else if first_matches_second_prefix && !second_matches_first_prefix {
    if same_action {
      RelationBroader
    } else {
      RelationConflict
    }
  } else if second_matches_first_prefix && !first_matches_second_prefix {
    if same_action {
      RelationNarrower
    } else {
      RelationConflict
    }
  } else {
    RelationIndependent
  }
  {
    first,
    second,
    relation,
    message: relation_message(relation, first, second),
    same_action,
    same_pattern,
    first_matches_second_prefix,
    second_matches_first_prefix,
  }
}

///|
pub fn analyze_rule_pairs(rules : Array[Rule]) -> Array[RulePairAnalysis] {
  let profiles = analyze_rules(rules)
  let pairs : Array[RulePairAnalysis] = []
  for left_index, left in profiles.iter2() {
    for right_index, right in profiles.iter2() {
      if left_index < right_index {
        pairs.push(compare_rule_profiles(left, right))
      }
    }
  }
  pairs
}

///|
pub fn AgentGroup::rule_pairs(self : AgentGroup) -> Array[RulePairAnalysis] {
  analyze_rule_pairs(self.rules)
}

///|
pub fn RuleProfile::matches_path(self : RuleProfile, path : StringView) -> Bool {
  rule_matches(self.rule.pattern, extract_path(path))
}

///|
pub fn RuleProfile::is_broad(self : RuleProfile) -> Bool {
  self.is_root_rule || self.kind == PatternCatchAll || self.literal_weight <= 1
}

///|
pub fn RuleProfile::is_narrow(self : RuleProfile) -> Bool {
  self.literal_weight >= 8 && self.depth >= 2
}

///|
pub fn RuleProfile::is_risky_block(self : RuleProfile) -> Bool {
  !self.rule.allow &&
  (self.risk == RuleRiskHigh || self.risk == RuleRiskCritical)
}

///|
pub fn RuleProfile::is_precise_allow(self : RuleProfile) -> Bool {
  self.rule.allow && self.is_narrow() && !self.is_wildcard_rule
}

///|
pub fn RuleProfile::action_label(self : RuleProfile) -> String {
  if self.rule.allow {
    "allow"
  } else {
    "disallow"
  }
}

///|
pub fn RuleProfile::to_line(self : RuleProfile) -> String {
  self.action_label() +
  " " +
  self.normalized_pattern +
  " [" +
  self.kind.label() +
  ", " +
  self.risk.label() +
  "] " +
  self.explanation
}

///|
pub fn RuleProfile::to_markdown_row(self : RuleProfile) -> String {
  "| " +
  self.rule.line.to_string() +
  " | " +
  self.action_label() +
  " | " +
  self.normalized_pattern.replace_all(old="|", new="\\|") +
  " | " +
  self.kind.label() +
  " | " +
  self.risk.label() +
  " | " +
  self.literal_weight.to_string() +
  " |"
}

///|
pub fn RuleProfile::notes_text(self : RuleProfile) -> String {
  if self.notes.is_empty() {
    "no notes"
  } else {
    self.notes.join("; ")
  }
}

///|
pub fn GroupRuleSummary::to_line(self : GroupRuleSummary) -> String {
  "agents=" +
  self.agents.join(",") +
  " rules=" +
  self.rule_count.to_string() +
  " allow=" +
  self.allow_count.to_string() +
  " disallow=" +
  self.disallow_count.to_string() +
  " wildcards=" +
  self.wildcard_rule_count.to_string()
}

///|
pub fn GroupRuleSummary::to_markdown(self : GroupRuleSummary) -> String {
  let lines : Array[String] = []
  lines.push("### Group " + self.agents.join(","))
  lines.push("")
  lines.push("- Start line: " + self.group_line.to_string())
  lines.push("- Rules: " + self.rule_count.to_string())
  lines.push("- Allow rules: " + self.allow_count.to_string())
  lines.push("- Disallow rules: " + self.disallow_count.to_string())
  lines.push("- Wildcard rules: " + self.wildcard_rule_count.to_string())
  lines.push("- Anchored rules: " + self.anchored_rule_count.to_string())
  lines.push("- Root blocks: " + self.root_block_count.to_string())
  lines.push("- Max depth: " + self.max_depth.to_string())
  lines.push("- Max literal weight: " + self.max_literal_weight.to_string())
  match self.crawl_delay {
    Some(delay) => lines.push("- Crawl delay: " + delay.to_string())
    None => lines.push("- Crawl delay: none")
  }
  if self.notes.is_empty() {
    lines.push("- Notes: none")
  } else {
    lines.push("- Notes: " + self.notes.join("; "))
  }
  lines.join("\n")
}

///|
pub fn PolicyProfile::to_line(self : PolicyProfile) -> String {
  "rules=" +
  self.total_rules.to_string() +
  " allow=" +
  self.total_allows.to_string() +
  " disallow=" +
  self.total_disallows.to_string() +
  " groups=" +
  self.group_summaries.length().to_string() +
  " issues=" +
  self.issue_count.to_string()
}

///|
pub fn PolicyProfile::to_markdown(self : PolicyProfile) -> String {
  let lines : Array[String] = []
  lines.push("# RoboPolicy Rule Profile")
  lines.push("")
  lines.push("- Groups: " + self.group_summaries.length().to_string())
  lines.push("- Rules: " + self.total_rules.to_string())
  lines.push("- Allow rules: " + self.total_allows.to_string())
  lines.push("- Disallow rules: " + self.total_disallows.to_string())
  lines.push("- Wildcard rules: " + self.total_wildcards.to_string())
  lines.push("- Anchored rules: " + self.total_anchors.to_string())
  lines.push("- Root blocks: " + self.total_root_blocks.to_string())
  lines.push("- Highest depth: " + self.highest_depth.to_string())
  lines.push("- Highest literal weight: " + self.highest_weight.to_string())
  lines.push("- Sitemap present: " + yes_no(self.has_sitemap))
  lines.push("- Parse issues: " + self.issue_count.to_string())
  lines.push("")
  if self.notes.is_empty() {
    lines.push("No profile notes.")
  } else {
    lines.push("Profile notes:")
    for note in self.notes {
      lines.push("- " + note)
    }
  }
  lines.push("")
  for summary in self.group_summaries {
    lines.push(summary.to_markdown())
    lines.push("")
  }
  lines.join("\n")
}

///|
pub fn RulePairAnalysis::to_line(self : RulePairAnalysis) -> String {
  self.relation.label() +
  ": " +
  self.first.normalized_pattern +
  " -> " +
  self.second.normalized_pattern +
  " " +
  self.message
}

///|
pub fn find_duplicate_rules(rules : Array[Rule]) -> Array[RulePairAnalysis] {
  filter_pairs_by_relation(analyze_rule_pairs(rules), RelationDuplicate)
}

///|
pub fn find_conflicting_rules(rules : Array[Rule]) -> Array[RulePairAnalysis] {
  filter_pairs_by_relation(analyze_rule_pairs(rules), RelationConflict)
}

///|
pub fn find_broad_rules(rules : Array[Rule]) -> Array[RuleProfile] {
  let profiles : Array[RuleProfile] = []
  for profile in analyze_rules(rules) {
    if profile.is_broad() {
      profiles.push(profile)
    }
  }
  profiles
}

///|
pub fn find_risky_blocks(rules : Array[Rule]) -> Array[RuleProfile] {
  let profiles : Array[RuleProfile] = []
  for profile in analyze_rules(rules) {
    if profile.is_risky_block() {
      profiles.push(profile)
    }
  }
  profiles
}

///|
pub fn find_precise_allows(rules : Array[Rule]) -> Array[RuleProfile] {
  let profiles : Array[RuleProfile] = []
  for profile in analyze_rules(rules) {
    if profile.is_precise_allow() {
      profiles.push(profile)
    }
  }
  profiles
}

///|
pub fn rules_to_markdown(rules : Array[Rule]) -> String {
  let lines : Array[String] = []
  lines.push("| Line | Action | Pattern | Kind | Risk | Weight |")
  lines.push("| --- | --- | --- | --- | --- | --- |")
  for profile in analyze_rules(rules) {
    lines.push(profile.to_markdown_row())
  }
  lines.join("\n")
}

///|
fn filter_pairs_by_relation(
  pairs : Array[RulePairAnalysis],
  relation : RuleRelation,
) -> Array[RulePairAnalysis] {
  let filtered : Array[RulePairAnalysis] = []
  for pair in pairs {
    if pair.relation == relation {
      filtered.push(pair)
    }
  }
  filtered
}

///|
fn normalize_rule_pattern(pattern : String) -> String {
  let trimmed = pattern.trim()
  if trimmed.is_empty() {
    ""
  } else if trimmed == "*" {
    "*"
  } else if trimmed == "/*" {
    "/*"
  } else if trimmed.has_prefix("http://") || trimmed.has_prefix("https://") {
    extract_path(trimmed)
  } else if trimmed.has_prefix("/") {
    trimmed.to_owned()
  } else {
    "/" + trimmed.to_owned()
  }
}

///|
fn classify_rule_pattern(
  pattern : String,
  is_empty_pattern : Bool,
  is_root_rule : Bool,
  is_directory_rule : Bool,
  is_file_rule : Bool,
  is_wildcard_rule : Bool,
  has_anchor : Bool,
) -> RulePatternKind {
  if is_empty_pattern {
    PatternEmpty
  } else if is_root_rule {
    PatternRoot
  } else if pattern == "*" || pattern == "/*" {
    PatternCatchAll
  } else if has_anchor {
    PatternAnchored
  } else if is_extension_glob(pattern) {
    PatternExtensionGlob
  } else if is_wildcard_rule && pattern.has_prefix("*") {
    PatternWildcardPrefix
  } else if is_wildcard_rule && pattern.has_suffix("*") {
    PatternWildcardSuffix
  } else if is_wildcard_rule {
    PatternWildcardMiddle
  } else if is_directory_rule {
    PatternDirectoryPrefix
  } else if is_file_rule {
    PatternFilePath
  } else {
    PatternExactPath
  }
}

///|
fn classify_rule_impact(
  allow : Bool,
  kind : RulePatternKind,
  is_root_rule : Bool,
  is_directory_rule : Bool,
  is_file_rule : Bool,
  is_wildcard_rule : Bool,
) -> RuleImpact {
  if allow && kind == PatternEmpty {
    ImpactAllowEmpty
  } else if allow && (is_directory_rule || is_wildcard_rule) {
    ImpactAllowWide
  } else if allow {
    ImpactAllowNarrow
  } else if is_root_rule || kind == PatternCatchAll {
    ImpactBlockRoot
  } else if is_directory_rule {
    ImpactBlockDirectory
  } else if is_file_rule {
    ImpactBlockFile
  } else if is_wildcard_rule {
    ImpactBlockWildcard
  } else {
    ImpactNeutral
  }
}

///|
fn classify_rule_risk(
  allow : Bool,
  kind : RulePatternKind,
  impact : RuleImpact,
  wildcard_count : Int,
  literal_weight : Int,
  has_query_like_text : Bool,
) -> RuleRiskBand {
  if !allow && impact == ImpactBlockRoot {
    RuleRiskCritical
  } else if !allow && kind == PatternCatchAll {
    RuleRiskCritical
  } else if !allow && literal_weight <= 1 {
    RuleRiskHigh
  } else if wildcard_count >= 2 {
    RuleRiskHigh
  } else if has_query_like_text {
    RuleRiskMedium
  } else if impact == ImpactAllowWide || impact == ImpactBlockWildcard {
    RuleRiskMedium
  } else {
    RuleRiskLow
  }
}

///|
fn rule_notes(
  rule : Rule,
  pattern : String,
  kind : RulePatternKind,
  impact : RuleImpact,
  risk : RuleRiskBand,
  wildcard_count : Int,
  has_query_like_text : Bool,
  has_fragment_like_text : Bool,
) -> Array[String] {
  let notes : Array[String] = []
  if pattern.is_empty() {
    notes.push("empty disallow has no blocking effect")
  }
  if !rule.allow && impact == ImpactBlockRoot {
    notes.push("this rule blocks the whole site")
  }
  if rule.allow && impact == ImpactAllowWide {
    notes.push("wide allow rule may override broader disallow rules")
  }
  if kind == PatternAnchored {
    notes.push("end anchor is used")
  }
  if wildcard_count > 0 {
    notes.push("wildcard count=" + wildcard_count.to_string())
  }
  if wildcard_count >= 2 {
    notes.push("multiple wildcards make the rule harder to explain")
  }
  if has_query_like_text {
    notes.push("query marker is present in pattern")
  }
  if has_fragment_like_text {
    notes.push("fragment marker is present in pattern")
  }
  if risk == RuleRiskCritical {
    notes.push("critical review recommended")
  }
  notes
}

///|
fn rule_explanation(
  rule : Rule,
  kind : RulePatternKind,
  impact : RuleImpact,
  risk : RuleRiskBand,
) -> String {
  let action = if rule.allow { "allows" } else { "blocks" }
  action +
  " " +
  kind.label() +
  " pattern with " +
  impact.label() +
  " impact and " +
  risk.label() +
  " risk"
}

///|
fn relation_message(
  relation : RuleRelation,
  first : RuleProfile,
  second : RuleProfile,
) -> String {
  match relation {
    RelationDuplicate => "rules have the same action and pattern"
    RelationBroader =>
      first.normalized_pattern + " is broader than " + second.normalized_pattern
    RelationNarrower =>
      first.normalized_pattern +
      " is narrower than " +
      second.normalized_pattern
    RelationConflict =>
      "rules may match overlapping paths with different actions"
    RelationIndependent => "rules appear to target independent path ranges"
  }
}

///|
fn rule_pattern_depth(pattern : String) -> Int {
  let clean = trim_rule_markers(pattern)
  if clean.is_empty() || clean == "/" || clean == "*" {
    0
  } else {
    path_depth(clean)
  }
}

///|
fn rule_prefix(pattern : String) -> String {
  let clean = trim_rule_markers(pattern)
  if clean.is_empty() {
    ""
  } else if clean == "*" {
    "/"
  } else {
    match clean.split_once("*") {
      Some((left, _)) => if left.is_empty() { "/" } else { left.to_owned() }
      None => clean
    }
  }
}

///|
fn rule_extension(pattern : String) -> String? {
  let clean = trim_rule_markers(pattern)
  if clean.contains("*") {
    match clean.split_once(".") {
      Some((_, ext)) => if ext.is_empty() { None } else { Some(ext.to_owned()) }
      None => None
    }
  } else {
    path_extension(clean)
  }
}

///|
fn trim_rule_markers(pattern : String) -> String {
  if pattern.has_suffix("$") {
    match pattern.strip_suffix("$") {
      Some(body) => body.to_owned()
      None => pattern
    }
  } else {
    pattern
  }
}

///|
fn looks_like_directory_rule(pattern : String) -> Bool {
  !pattern.is_empty() && pattern.has_suffix("/") && pattern != "/"
}

///|
fn looks_like_file_rule(pattern : String) -> Bool {
  !pattern.is_empty() &&
  !pattern.has_suffix("/") &&
  pattern.contains(".") &&
  !pattern.contains("*")
}

///|
fn is_extension_glob(pattern : String) -> Bool {
  pattern.contains("*.") || pattern.contains("/*.")
}

///|
fn prefix_covers(prefix : String, candidate : String) -> Bool {
  if prefix.is_empty() {
    false
  } else if prefix == "/" {
    candidate.has_prefix("/")
  } else {
    candidate.has_prefix(prefix)
  }
}

///|
fn count_char(text : String, needle : Char) -> Int {
  let mut count = 0
  for ch in text {
    if ch == needle {
      count = count + 1
    }
  }
  count
}

///|
fn yes_no(value : Bool) -> String {
  if value {
    "yes"
  } else {
    "no"
  }
}