///|
pub(all) enum RiskBand {
  Low
  Guarded
  High
  Critical
} derive(Eq, Debug, ToJson)

///|
pub(all) enum ModelIssueKind {
  EmptyModel
  OrphanNode
  MissingSource
  MissingSink
  BrokenEdge
  UnreachableSink
  UnusedPolicy
  InvalidSeverity
  DuplicatePolicy
} derive(Eq, Debug, ToJson)

///|
pub(all) struct GraphMetrics {
  node_count : Int
  edge_count : Int
  source_count : Int
  sink_count : Int
  sanitizer_count : Int
  boundary_count : Int
  policy_count : Int
  reachable_source_count : Int
  reachable_sink_count : Int
  max_out_degree : Int
  max_in_degree : Int
  connected_component_count : Int
  cycle_hint_count : Int
} derive(Eq, Debug, ToJson)

///|
pub(all) struct ModelIssue {
  kind : ModelIssueKind
  subject : String
  detail : String
  severity : String
  recommendation : String
} derive(Eq, Debug, ToJson)

///|
pub(all) struct PolicyCoverage {
  allow_count : Int
  deny_count : Int
  require_count : Int
  covered_policy_count : Int
  reachable_policy_count : Int
  high_severity_count : Int
  medium_severity_count : Int
  low_severity_count : Int
  coverage_percent : Int
} derive(Eq, Debug, ToJson)

///|
pub(all) struct ModelAssessment {
  metrics : GraphMetrics
  coverage : PolicyCoverage
  issues : Array[ModelIssue]
  risk_score : Int
  risk_band : RiskBand
  recommendation_count : Int
} derive(Eq, Debug, ToJson)

///|
pub fn risk_band_name(band : RiskBand) -> String {
  match band {
    Low => "low"
    Guarded => "guarded"
    High => "high"
    Critical => "critical"
  }
}

///|
pub fn issue_kind_name(kind : ModelIssueKind) -> String {
  match kind {
    EmptyModel => "empty_model"
    OrphanNode => "orphan_node"
    MissingSource => "missing_source"
    MissingSink => "missing_sink"
    BrokenEdge => "broken_edge"
    UnreachableSink => "unreachable_sink"
    UnusedPolicy => "unused_policy"
    InvalidSeverity => "invalid_severity"
    DuplicatePolicy => "duplicate_policy"
  }
}

///|
pub fn measure_model(model : Model) -> GraphMetrics {
  let source_count = count_nodes_of_kind(model, Source)
  let sink_count = count_nodes_of_kind(model, Sink)
  let sanitizer_count = count_nodes_of_kind(model, Sanitizer)
  let boundary_count = count_nodes_of_kind(model, Boundary)
  let mut max_out_degree = 0
  let mut max_in_degree = 0
  for node in model.nodes {
    let out_degree = outgoing_count(model, node.name)
    let in_degree = incoming_count(model, node.name)
    if out_degree > max_out_degree {
      max_out_degree = out_degree
    }
    if in_degree > max_in_degree {
      max_in_degree = in_degree
    }
  }
  let mut reachable_source_count = 0
  let mut reachable_sink_count = 0
  for node in model.nodes {
    if node.kind == Source && has_reachable_sink(model, node.name) {
      reachable_source_count += 1
    }
    if node.kind == Sink && has_reachable_source(model, node.name) {
      reachable_sink_count += 1
    }
  }
  {
    node_count: model.nodes.length(),
    edge_count: model.edges.length(),
    source_count,
    sink_count,
    sanitizer_count,
    boundary_count,
    policy_count: model.policies.length(),
    reachable_source_count,
    reachable_sink_count,
    max_out_degree,
    max_in_degree,
    connected_component_count: count_components(model),
    cycle_hint_count: count_cycle_hints(model),
  }
}

///|
pub fn assess_model(model : Model) -> ModelAssessment {
  let metrics = measure_model(model)
  let coverage = measure_policy_coverage(model)
  let issues = collect_model_issues(model, metrics, coverage)
  let risk_score = calculate_risk_score(metrics, coverage, issues)
  {
    metrics,
    coverage,
    issues,
    risk_score,
    risk_band: risk_band_for_score(risk_score),
    recommendation_count: count_recommendations(metrics, coverage),
  }
}

///|
pub fn assessment_json(assessment : ModelAssessment) -> String {
  assessment.to_json().stringify(indent=2)
}

///|
pub fn format_assessment(assessment : ModelAssessment) -> String {
  let out = StringBuilder()
  out.write_string("model assessment")
  out.write_string("\nrisk_score=\{assessment.risk_score}")
  out.write_string("\nrisk_band=\{risk_band_name(assessment.risk_band)}")
  out.write_string(
    "\nnodes=\{assessment.metrics.node_count}, edges=\{assessment.metrics.edge_count}, policies=\{assessment.metrics.policy_count}",
  )
  out.write_string("\npolicy_coverage=\{assessment.coverage.coverage_percent}%")
  out.write_string("\nissues=\{assessment.issues.length()}")
  for issue in assessment.issues {
    out.write_string(
      "\n[\{issue.severity}] \{issue_kind_name(issue.kind)}: \{issue.subject} - \{issue.detail}",
    )
    out.write_string("\n  recommendation: \{issue.recommendation}")
  }
  out.to_string()
}

///|
fn count_nodes_of_kind(model : Model, wanted : NodeKind) -> Int {
  let mut total = 0
  for node in model.nodes {
    if node.kind == wanted {
      total += 1
    }
  }
  total
}

///|
fn outgoing_count(model : Model, name : String) -> Int {
  let mut total = 0
  for edge in model.edges {
    if edge.from == name {
      total += 1
    }
  }
  total
}

///|
fn incoming_count(model : Model, name : String) -> Int {
  let mut total = 0
  for edge in model.edges {
    if edge.to == name {
      total += 1
    }
  }
  total
}

///|
fn has_node_named(model : Model, name : String) -> Bool {
  for node in model.nodes {
    if node.name == name {
      return true
    }
  }
  false
}

///|
fn node_kind_for(model : Model, name : String) -> NodeKind? {
  for node in model.nodes {
    if node.name == name {
      return Some(node.kind)
    }
  }
  None
}

///|
fn has_reachable_sink(model : Model, start : String) -> Bool {
  let seen : Array[String] = []
  let queue : Array[String] = [start]
  for ;; {
    if queue.length() == 0 {
      break
    }
    let current = queue.remove(0)
    if seen.contains(current) {
      continue
    }
    seen.push(current)
    match node_kind_for(model, current) {
      Some(Sink) => return true
      _ => ()
    }
    for edge in model.edges {
      if edge.from == current && !seen.contains(edge.to) {
        queue.push(edge.to)
      }
    }
  }
  false
}

///|
fn has_reachable_source(model : Model, start : String) -> Bool {
  let seen : Array[String] = []
  let queue : Array[String] = [start]
  for ;; {
    if queue.length() == 0 {
      break
    }
    let current = queue.remove(0)
    if seen.contains(current) {
      continue
    }
    seen.push(current)
    match node_kind_for(model, current) {
      Some(Source) => return true
      _ => ()
    }
    for edge in model.edges {
      if edge.to == current && !seen.contains(edge.from) {
        queue.push(edge.from)
      }
    }
  }
  false
}

///|
fn count_components(model : Model) -> Int {
  let seen : Array[String] = []
  let mut components = 0
  for node in model.nodes {
    if !seen.contains(node.name) {
      components += 1
      collect_component(model, node.name, seen)
    }
  }
  components
}

///|
fn collect_component(
  model : Model,
  start : String,
  seen : Array[String],
) -> Unit {
  let queue : Array[String] = [start]
  for ;; {
    if queue.length() == 0 {
      break
    }
    let current = queue.remove(0)
    if seen.contains(current) {
      continue
    }
    seen.push(current)
    for edge in model.edges {
      if edge.from == current && !seen.contains(edge.to) {
        queue.push(edge.to)
      }
      if edge.to == current && !seen.contains(edge.from) {
        queue.push(edge.from)
      }
    }
  }
}

///|
fn count_cycle_hints(model : Model) -> Int {
  let mut count = 0
  for edge in model.edges {
    if edge.from == edge.to || has_path(model, edge.to, edge.from) {
      count += 1
    }
  }
  count
}

///|
fn has_path(model : Model, start : String, target : String) -> Bool {
  if start == target {
    return true
  }
  let seen : Array[String] = []
  let queue : Array[String] = [start]
  for ;; {
    if queue.length() == 0 {
      break
    }
    let current = queue.remove(0)
    if seen.contains(current) {
      continue
    }
    seen.push(current)
    for edge in model.edges {
      if edge.from == current {
        if edge.to == target {
          return true
        }
        if !seen.contains(edge.to) {
          queue.push(edge.to)
        }
      }
    }
  }
  false
}

///|
fn measure_policy_coverage(model : Model) -> PolicyCoverage {
  let mut allow_count = 0
  let mut deny_count = 0
  let mut require_count = 0
  let mut reachable_count = 0
  let mut covered_count = 0
  let mut high_count = 0
  let mut medium_count = 0
  let mut low_count = 0
  for policy in model.policies {
    match policy.kind {
      Allow => allow_count += 1
      Deny => deny_count += 1
      Require => require_count += 1
    }
    match policy.severity {
      "high" => high_count += 1
      "medium" => medium_count += 1
      _ => low_count += 1
    }
    if policy.path.length() >= 2 &&
      has_path(model, policy.path[0], policy.path[policy.path.length() - 1]) {
      reachable_count += 1
      if policy.kind == Allow ||
        policy.through == "" ||
        has_policy_control_point(model, policy) {
        covered_count += 1
      }
    }
  }
  let coverage_percent = if model.policies.length() == 0 {
    0
  } else {
    covered_count * 100 / model.policies.length()
  }
  {
    allow_count,
    deny_count,
    require_count,
    covered_policy_count: covered_count,
    reachable_policy_count: reachable_count,
    high_severity_count: high_count,
    medium_severity_count: medium_count,
    low_severity_count: low_count,
    coverage_percent,
  }
}

///|
fn has_policy_control_point(model : Model, policy : Policy) -> Bool {
  if policy.through == "" {
    return true
  }
  for edge in model.edges {
    if edge.from == policy.through || edge.to == policy.through {
      return true
    }
  }
  false
}

///|
fn collect_model_issues(
  model : Model,
  metrics : GraphMetrics,
  coverage : PolicyCoverage,
) -> Array[ModelIssue] {
  let issues : Array[ModelIssue] = []
  if model.nodes.length() == 0 {
    issues.push({
      kind: EmptyModel,
      subject: "model",
      detail: "the model contains no nodes",
      severity: "high",
      recommendation: "add at least one source, boundary, and sink",
    })
  }
  if metrics.source_count == 0 {
    issues.push({
      kind: MissingSource,
      subject: "source",
      detail: "no external input node is declared",
      severity: "medium",
      recommendation: "declare the trust boundary where data enters the system",
    })
  }
  if metrics.sink_count == 0 {
    issues.push({
      kind: MissingSink,
      subject: "sink",
      detail: "no security-sensitive output node is declared",
      severity: "medium",
      recommendation: "declare a sink such as a renderer, database, or command executor",
    })
  }
  for edge in model.edges {
    if !has_node_named(model, edge.from) || !has_node_named(model, edge.to) {
      issues.push({
        kind: BrokenEdge,
        subject: "\{edge.from} -> \{edge.to}",
        detail: "edge references a node that is not declared",
        severity: "high",
        recommendation: "declare both endpoints or remove the edge",
      })
    }
  }
  for node in model.nodes {
    if outgoing_count(model, node.name) == 0 &&
      incoming_count(model, node.name) == 0 {
      issues.push({
        kind: OrphanNode,
        subject: node.name,
        detail: "node has no incoming or outgoing edge",
        severity: "low",
        recommendation: "connect the node to a real flow or remove it from the model",
      })
    }
    if node.kind == Sink && !has_reachable_source(model, node.name) {
      issues.push({
        kind: UnreachableSink,
        subject: node.name,
        detail: "sink cannot be reached from any source",
        severity: "medium",
        recommendation: "add the missing service path or explain why the sink is isolated",
      })
    }
  }
  for policy in model.policies {
    if policy.severity != "high" &&
      policy.severity != "medium" &&
      policy.severity != "low" &&
      policy.severity != "info" {
      issues.push({
        kind: InvalidSeverity,
        subject: policy.description,
        detail: "severity is not one of high, medium, low, or info",
        severity: "medium",
        recommendation: "use a supported severity so CI and reports remain comparable",
      })
    }
    if policy.path.length() < 2 ||
      !has_path(model, policy.path[0], policy.path[policy.path.length() - 1]) {
      issues.push({
        kind: UnusedPolicy,
        subject: policy.description,
        detail: "policy endpoint path is not reachable in the graph",
        severity: "low",
        recommendation: "check endpoint names and keep intentional future rules documented",
      })
    }
  }
  if model.policies.length() > 0 && coverage.covered_policy_count == 0 {
    issues.push({
      kind: UnusedPolicy,
      subject: "policy-set",
      detail: "no policy currently covers a reachable path",
      severity: "high",
      recommendation: "add executable rules for the declared source-to-sink flows",
    })
  }
  issues
}

///|
fn calculate_risk_score(
  metrics : GraphMetrics,
  coverage : PolicyCoverage,
  issues : Array[ModelIssue],
) -> Int {
  let mut score = 0
  score += metrics.sink_count * 2
  score += metrics.edge_count / 4
  score += metrics.cycle_hint_count
  score += (100 - coverage.coverage_percent) / 5
  for issue in issues {
    match issue.severity {
      "high" => score += 12
      "medium" => score += 6
      "low" => score += 2
      _ => score += 1
    }
  }
  if score > 100 {
    100
  } else {
    score
  }
}

///|
fn risk_band_for_score(score : Int) -> RiskBand {
  if score >= 75 {
    Critical
  } else if score >= 45 {
    High
  } else if score >= 20 {
    Guarded
  } else {
    Low
  }
}

///|
fn count_recommendations(
  metrics : GraphMetrics,
  coverage : PolicyCoverage,
) -> Int {
  let mut count = 0
  if metrics.source_count == 0 {
    count += 1
  }
  if metrics.sink_count == 0 {
    count += 1
  }
  if metrics.reachable_sink_count < metrics.sink_count {
    count += metrics.sink_count - metrics.reachable_sink_count
  }
  if coverage.coverage_percent < 100 {
    count += 1
  }
  count
}