///|
pub(all) enum ChangeCause {
  RuleRevision
  RoleInheritanceRevision
  EitherRevision
  CombinedRevision
  Unattributed
} derive(Eq, Debug)

///|
pub(all) struct AttributedChange {
  request : AccessRequest
  kind : ChangeKind
  cause : ChangeCause
  changed_rule_ids : Array[String]
  role_edges_changed : Bool
} derive(Debug)

///|
fn find_rule(policy : AccessPolicy, id : String) -> AccessRule? {
  for rule in policy.rules {
    if rule.id == id {
      return Some(rule)
    }
  }
  None
}

///|
fn changed_rules(before : AccessPolicy, after : AccessPolicy) -> Array[String] {
  let changed : Array[String] = []
  for rule in before.rules {
    match find_rule(after, rule.id) {
      None => changed.push(rule.id)
      Some(next) => if next != rule { changed.push(rule.id) }
    }
  }
  for rule in after.rules {
    if find_rule(before, rule.id) is None {
      changed.push(rule.id)
    }
  }
  changed
}

///|
fn target_decision(
  policy : AccessPolicy,
  universe : RequestUniverse,
  request : AccessRequest,
  target : DecisionKind,
) -> Bool {
  evaluate_request(policy, universe, request).kind == target
}

///|
/// A two-factor counterfactual: swap only the rules, then only the role graph.
/// The result explains the observed decision for the supplied request and
/// these two policy dimensions; it is not a general causal proof.
pub fn attribute_changes(
  before : AccessPolicy,
  after : AccessPolicy,
  universe : RequestUniverse,
) -> Array[AttributedChange] {
  let result : Array[AttributedChange] = []
  let report = compare_policies(before, after, universe)
  if !report.complete {
    return result
  }
  let rule_ids = changed_rules(before, after)
  let edges_changed = before.role_inheritance != after.role_inheritance
  let rule_only : AccessPolicy = {
    name: "rule-counterfactual",
    role_inheritance: before.role_inheritance,
    rules: after.rules,
  }
  let role_only : AccessPolicy = {
    name: "role-counterfactual",
    role_inheritance: after.role_inheritance,
    rules: before.rules,
  }
  for change in report.changes {
    if change.kind is NewGrant || change.kind is RevokedGrant {
      let rules_reach_target = target_decision(
        rule_only,
        universe,
        change.request,
        change.after.kind,
      )
      let roles_reach_target = target_decision(
        role_only,
        universe,
        change.request,
        change.after.kind,
      )
      let cause = match (rules_reach_target, roles_reach_target) {
        (true, true) => EitherRevision
        (true, false) => RuleRevision
        (false, true) => RoleInheritanceRevision
        (false, false) =>
          if rule_ids.length() > 0 && edges_changed {
            CombinedRevision
          } else {
            Unattributed
          }
      }
      result.push({
        request: change.request,
        kind: change.kind,
        cause,
        changed_rule_ids: rule_ids,
        role_edges_changed: edges_changed,
      })
    }
  }
  result
}

///|
pub fn cause_name(cause : ChangeCause) -> String {
  match cause {
    RuleRevision => "rule revision"
    RoleInheritanceRevision => "role inheritance revision"
    EitherRevision => "either revision independently"
    CombinedRevision => "combined rule and role revisions"
    Unattributed => "unattributed"
  }
}

///|
pub fn render_attribution(changes : Array[AttributedChange]) -> String {
  let out = StringBuilder()
  out.write_string("Attributed permission changes: \{changes.length()}\n")
  for change in changes {
    out.write_string(
      "\{change_name(change.kind)} \{change.request.principal_id} \{change.request.action} \{change.request.resource_id}: \{cause_name(change.cause)}\n",
    )
    out.write_string("  changed rules: ")
    write_ids(out, change.changed_rule_ids)
    out.write_string("\n")
  }
  out.to_string()
}