// Copyright 2026 moonpddl contributors
//
// SPDX-License-Identifier: MIT

///|
/// Categories of validation problems.
pub(all) enum ValidationIssueKind {
  PlanSyntax
  UnknownAction
  Inapplicable
  GoalNotReached
  CostMismatch
} derive(Eq, Debug)

///|
/// One diagnostic produced while parsing or executing a plan. `line` and
/// `step` are 1-based and are `0` when the issue is not tied to a line or a
/// step.
pub(all) struct ValidationIssue {
  kind : ValidationIssueKind
  line : Int32
  step : Int32
  message : String
} derive(Debug)

///|
/// The result of checking a plan. A cost comment mismatch is an error: the
/// plan file makes a claim that the validator could not reproduce.
pub(all) struct ValidationReport {
  valid : Bool
  steps : Int32
  executed : Int32
  cost : Double
  declared_cost : Double?
  issues : Array[ValidationIssue]
} derive(Debug)

///|
pub fn ValidationReport::message(self : ValidationReport) -> String {
  if self.valid {
    if self.issues.length() > 0 {
      "plan valid: \{self.executed} step(s), cost \{format_number(self.cost)} (\{self.issues.length()} warning(s))"
    } else {
      "plan valid: \{self.executed} step(s), cost \{format_number(self.cost)}"
    }
  } else {
    "plan invalid"
  }
}

///|
pub fn ValidationIssueKind::name(self : ValidationIssueKind) -> String {
  match self {
    PlanSyntax => "plan-syntax"
    UnknownAction => "unknown-action"
    Inapplicable => "inapplicable"
    GoalNotReached => "goal-not-reached"
    CostMismatch => "cost-mismatch"
  }
}

///|
fn json_escape(text : String) -> String {
  let sb = StringBuilder::StringBuilder()
  for c in text.to_array() {
    match c {
      '"' => sb.write_string("\\\"")
      '\\' => sb.write_string("\\\\")
      '\n' => sb.write_string("\\n")
      '\r' => sb.write_string("\\r")
      '\t' => sb.write_string("\\t")
      _ => sb.write_char(c)
    }
  }
  sb.to_string()
}

///|
fn json_number(value : Double) -> String {
  format_number(value)
}

///|
/// Serializes the report as a single JSON object.
pub fn ValidationReport::to_json(self : ValidationReport) -> String {
  let sb = StringBuilder::StringBuilder()
  sb.write_string("{\"valid\":")
  if self.valid {
    sb.write_string("true")
  } else {
    sb.write_string("false")
  }
  sb.write_string(",\"steps\":")
  sb.write_string(self.steps.to_string())
  sb.write_string(",\"executed\":")
  sb.write_string(self.executed.to_string())
  sb.write_string(",\"cost\":")
  sb.write_string(json_number(self.cost))
  sb.write_string(",\"declared_cost\":")
  match self.declared_cost {
    Some(value) => sb.write_string(json_number(value))
    None => sb.write_string("null")
  }
  sb.write_string(",\"issues\":[")
  for index in 0.. 0 {
      sb.write_char(',')
    }
    let issue = self.issues[index]
    sb.write_string("{\"kind\":\"")
    sb.write_string(issue.kind.name())
    sb.write_string("\",\"line\":")
    sb.write_string(issue.line.to_string())
    sb.write_string(",\"step\":")
    sb.write_string(issue.step.to_string())
    sb.write_string(",\"message\":\"")
    sb.write_string(json_escape(issue.message))
    sb.write_string("\"}")
  }
  sb.write_string("]}")
  sb.to_string()
}

///|
priv struct PlanStep {
  name : String
  args : Array[String]
  line : Int32
}

///|
priv struct ParsedPlan {
  steps : Array[PlanStep]
  issues : Array[ValidationIssue]
  declared_cost : Double?
  declared_line : Int32
}

///|
fn split_comment(line : String) -> (String, String) {
  match line.find(";") {
    Some(index) => (line[:index].to_owned(), line[index + 1:].to_owned())
    None => (line, "")
  }
}

///|
/// Parses a `; cost = N` comment. Integers and decimals are accepted;
/// anything else that mentions cost is reported as malformed.
fn parse_declared_cost(comment : String) -> (Bool, Double?) {
  let lower = comment.to_lower()
  if !lower.contains("cost") {
    return (false, None)
  }
  let equals = match lower.find("=") {
    Some(index) => index
    None => return (false, None)
  }
  let tail = lower[equals + 1:].trim().to_owned()
  if tail.length() == 0 {
    return (true, None)
  }
  let chars = tail.to_array()
  if chars[0] < '0' || chars[0] > '9' {
    return (true, None)
  }
  let mut index = 1
  while index < chars.length() && chars[index] >= '0' && chars[index] <= '9' {
    index += 1
  }
  if index < chars.length() && chars[index] == '.' {
    index += 1
    while index < chars.length() && chars[index] >= '0' && chars[index] <= '9' {
      index += 1
    }
  }
  if index < chars.length() {
    let next = chars[index]
    if !next.is_whitespace() && next != ',' && next != ')' && next != ';' {
      return (true, None)
    }
  }
  let number = tail[:index].to_owned()
  if number == "" {
    return (true, None)
  }
  (true, parse_plan_number(number))
}

///|
/// Parses a plan cost comment number: optional sign, digits, optional
/// fraction. Sums are accumulated in `Double`, so 15 significant digits
/// stay exact.
fn parse_plan_number(text : String) -> Double? {
  let chars = text.to_array()
  if chars.length() == 0 {
    return None
  }
  let mut index = 0
  let mut negative = false
  if chars[0] == '-' {
    negative = true
    index = 1
  }
  let mut int_digits = 0
  let mut int_part = 0.0
  while index < chars.length() && chars[index] >= '0' && chars[index] <= '9' {
    int_part = int_part * 10.0 + (chars[index].to_int() - 48).to_double()
    int_digits += 1
    index += 1
  }
  if int_digits == 0 {
    return None
  }
  if index < chars.length() && chars[index] == '.' {
    index += 1
    let mut frac_digits = 0
    let mut frac_part = 0.0
    while index < chars.length() && chars[index] >= '0' && chars[index] <= '9' {
      frac_part = frac_part * 10.0 + (chars[index].to_int() - 48).to_double()
      frac_digits += 1
      index += 1
    }
    if frac_digits == 0 {
      return None
    }
    let mut scale = 1.0
    let mut k = 0
    while k < frac_digits {
      scale = scale * 10.0
      k += 1
    }
    int_part = int_part + frac_part / scale
  }
  if index != chars.length() {
    return None
  }
  if negative {
    int_part = -int_part
  }
  Some(int_part)
}

///|
fn tokenize_plan_step(text : String) -> Array[String] {
  let tokens : Array[String] = []
  let mut builder = StringBuilder::StringBuilder()
  let mut has_token = false
  for c in text.to_array() {
    if c.is_whitespace() {
      if has_token {
        tokens.push(builder.to_string())
        builder = StringBuilder::StringBuilder()
        has_token = false
      }
    } else {
      builder.write_char(c)
      has_token = true
    }
  }
  if has_token {
    tokens.push(builder.to_string())
  }
  tokens
}

///|
fn syntax_issue(line : Int32, message : String) -> ValidationIssue {
  { kind: PlanSyntax, line, step: 0, message, }
}

///|
fn parse_plan_step(
  body : String,
  line : Int32,
  issues : Array[ValidationIssue],
) -> PlanStep? {
  let trimmed = body.trim().to_owned()
  if trimmed.length() == 0 {
    return None
  }
  let mut inner = trimmed
  if trimmed.has_prefix("(") {
    if !trimmed.has_suffix(")") {
      issues.push(
        syntax_issue(
          line,
          "line \{line}: missing ')' in plan step '\{trimmed}'",
        ),
      )
      return None
    }
    inner = trimmed[1:trimmed.length() - 1].to_owned()
    if inner.contains("(") || inner.contains(")") {
      issues.push(
        syntax_issue(
          line,
          "line \{line}: nested parentheses in plan step '\{trimmed}'",
        ),
      )
      return None
    }
  } else if trimmed.contains("(") || trimmed.contains(")") {
    issues.push(
      syntax_issue(
        line,
        "line \{line}: unexpected parenthesis in plan step '\{trimmed}'",
      ),
    )
    return None
  }
  let tokens = tokenize_plan_step(inner)
  if tokens.length() == 0 {
    issues.push(syntax_issue(line, "line \{line}: empty plan step"))
    return None
  }
  let name = tokens[0].to_lower()
  let args : Array[String] = []
  for index in 1.. ParsedPlan {
  let steps : Array[PlanStep] = []
  let issues : Array[ValidationIssue] = []
  let mut declared_cost : Double? = None
  let mut declared_line = 0
  let mut line_number = 0
  for raw_line in source.split("\n") {
    line_number += 1
    let raw_text = raw_line.to_owned()
    let line = raw_text.trim().to_owned()
    let (body, comment) = split_comment(line)
    let (mentions_cost, parsed_cost) = parse_declared_cost(comment)
    match parsed_cost {
      Some(value) => {
        declared_cost = Some(value)
        declared_line = line_number
      }
      None =>
        if mentions_cost {
          issues.push(
            syntax_issue(
              line_number,
              "line \{line_number}: malformed cost comment; expected '; cost = '",
            ),
          )
        }
    }
    match parse_plan_step(body, line_number, issues) {
      Some(step) => steps.push(step)
      None => ()
    }
  }
  { steps, issues, declared_cost, declared_line, }
}

///|
fn render_plan_step(step : PlanStep) -> String {
  let sb = StringBuilder::StringBuilder()
  sb.write_char('(')
  sb.write_string(step.name)
  for arg in step.args {
    sb.write_char(' ')
    sb.write_string(arg)
  }
  sb.write_char(')')
  sb.to_string()
}

///|
fn literal_text(problem : GroundProblem, literal : CondLit) -> String {
  cond_lit_text(problem, literal)
}

///|
/// Describes the unsatisfied literals of the closest DNF alternative.
fn closest_unsatisfied(
  problem : GroundProblem,
  condition : Array[Array[CondLit]],
  state : State,
) -> String {
  if condition.length() == 0 {
    return "the condition has no satisfiable disjunct"
  }
  let mut chosen : Array[String] = []
  let mut chosen_count = -1
  for alternative in condition {
    let missing : Array[String] = []
    for literal in alternative {
      if !literal.holds(state) {
        missing.push(literal_text(problem, literal))
      }
    }
    if chosen_count < 0 || missing.length() < chosen_count {
      chosen_count = missing.length()
      chosen = missing
    }
  }
  if chosen.length() == 0 {
    return "none"
  }
  let sb = StringBuilder::StringBuilder()
  let limit = if chosen.length() < 6 { chosen.length() } else { 6 }
  for index in 0.. 0 {
      sb.write_string(", ")
    }
    sb.write_string(chosen[index])
  }
  if chosen.length() > limit {
    sb.write_string(", ...")
  }
  sb.to_string()
}

///|
fn issue(
  kind : ValidationIssueKind,
  line : Int32,
  step : Int32,
  message : String,
) -> ValidationIssue {
  { kind, line, step, message, }
}

///|
/// Executes `source` against an already grounded problem and reports the
/// first failure together with the plan cost.
pub fn validate_plan(
  problem : GroundProblem,
  source : String,
) -> ValidationReport {
  let parsed = parse_plan(source)
  if parsed.issues.length() > 0 {
    return {
      valid: false,
      steps: parsed.steps.length(),
      executed: 0,
      cost: 0.0,
      declared_cost: parsed.declared_cost,
      issues: parsed.issues,
    }
  }
  let lookup : Map[String, Int32] = Map([])
  let names : Map[String, Bool] = Map([])
  for index in 0.. index
      None => {
        let message = if names.contains(step.name) {
          "step \{executed + 1} (\{render_plan_step(step)}) does not match any grounded action; check the arguments and their types"
        } else {
          "step \{executed + 1} (\{render_plan_step(step)}) uses unknown action '\{step.name}'"
        }
        issues.push(issue(UnknownAction, step.line, executed + 1, message))
        return {
          valid: false,
          steps: parsed.steps.length(),
          executed,
          cost,
          declared_cost: parsed.declared_cost,
          issues,
        }
      }
    }
    let action = problem.actions[index]
    if !action_is_applicable(action, state) {
      let detail = closest_unsatisfied(problem, action.precondition, state)
      let message = "step \{executed + 1} (\{action.render()}) is not applicable; unsatisfied preconditions: \{detail}"
      issues.push(issue(Inapplicable, step.line, executed + 1, message))
      return {
        valid: false,
        steps: parsed.steps.length(),
        executed,
        cost,
        declared_cost: parsed.declared_cost,
        issues,
      }
    }
    state = apply_action(action, state)
    cost = capped_add(cost, action.cost)
    executed += 1
  }
  let mut valid = problem.goal_holds(state)
  if !valid {
    let detail = closest_unsatisfied(problem, problem.goal, state)
    let message = "goal is not satisfied after \{executed} step(s); unsatisfied goal literals: \{detail}"
    issues.push(issue(GoalNotReached, 0, 0, message))
  } else {
    match parsed.declared_cost {
      Some(declared) =>
        if num_compare(CmpNE, declared, cost) {
          let message = "declared plan cost \{format_number(declared)} does not match computed cost \{format_number(cost)}"
          issues.push(issue(CostMismatch, parsed.declared_line, 0, message))
          valid = false
        }
      None => ()
    }
  }
  {
    valid,
    steps: parsed.steps.length(),
    executed,
    cost,
    declared_cost: parsed.declared_cost,
    issues,
  }
}

///|
/// Parses and grounds the model, then validates the plan against it.
pub fn validate(
  domain : Domain,
  problem : Problem,
  source : String,
) -> ValidationReport raise PddlError {
  validate_plan(ground(domain, problem), source)
}