///|
fn json_escape(value : String) -> String {
  let builder = StringBuilder::new()
  for char in value {
    match char {
      '"' => builder.write_string("\\\"")
      '\\' => builder.write_string("\\\\")
      '\n' => builder.write_string("\\n")
      '\r' => builder.write_string("\\r")
      '\t' => builder.write_string("\\t")
      _ => builder.write_char(char)
    }
  }
  builder.to_string()
}

///|
fn json_string(value : String) -> String {
  "\"\{json_escape(value)}\""
}

///|
fn json_bool(value : Bool) -> String {
  if value {
    "true"
  } else {
    "false"
  }
}

///|
fn write_json_string_array(
  builder : StringBuilder,
  values : Array[String],
) -> Unit {
  builder.write_char('[')
  for index, value in values {
    if index > 0 {
      builder.write_char(',')
    }
    builder.write_string(json_string(value))
  }
  builder.write_char(']')
}

///|
/// Render one assignment as a stable JSON object.
pub fn assignment_to_json(value : Assignment) -> String {
  let builder = StringBuilder::new()
  builder.write_string("{\"visit_id\":")
  builder.write_string(json_string(value.visit_id))
  builder.write_string(",\"worker_id\":")
  builder.write_string(json_string(value.worker_id))
  builder.write_string(",\"start_minute\":\{value.start_minute}")
  builder.write_string(",\"end_minute\":\{value.end_minute}")
  builder.write_string(
    ",\"travel_before_minutes\":\{value.travel_before_minutes}",
  )
  builder.write_string(",\"score\":\{value.score},\"reasons\":")
  write_json_string_array(builder, value.reasons)
  builder.write_char('}')
  builder.to_string()
}

///|
/// Serialize a schedule for API consumers and reproducibility tests.
pub fn schedule_to_json(value : Schedule) -> String {
  let builder = StringBuilder::new()
  builder.write_string("{\"schema\":")
  builder.write_string(json_string(value.schema))
  builder.write_string(",\"feasible\":\{json_bool(value.feasible)}")
  builder.write_string(",\"score\":{")
  builder.write_string("\"total\":\{value.score.total}")
  builder.write_string(
    ",\"assigned_priority\":\{value.score.assigned_priority}",
  )
  builder.write_string(",\"travel_penalty\":\{value.score.travel_penalty}")
  builder.write_string(",\"fairness_penalty\":\{value.score.fairness_penalty}")
  builder.write_string(",\"continuity_bonus\":\{value.score.continuity_bonus}")
  builder.write_string(",\"preference_bonus\":\{value.score.preference_bonus}")
  builder.write_string(
    ",\"disruption_penalty\":\{value.score.disruption_penalty}}",
  )
  builder.write_string(",\"assignments\":[")
  for index, assignment in value.assignments {
    if index > 0 {
      builder.write_char(',')
    }
    builder.write_string(assignment_to_json(assignment))
  }
  builder.write_string("],\"unassigned\":[")
  for index, missing in value.unassigned {
    if index > 0 {
      builder.write_char(',')
    }
    builder.write_string("{\"visit_id\":")
    builder.write_string(json_string(missing.visit_id))
    builder.write_string(",\"reason_codes\":")
    write_json_string_array(builder, missing.reason_codes)
    builder.write_string(",\"details\":")
    write_json_string_array(builder, missing.details)
    builder.write_char('}')
  }
  builder.write_string("],\"diagnostics\":[")
  for index, issue in value.diagnostics {
    if index > 0 {
      builder.write_char(',')
    }
    builder.write_string("{\"level\":")
    builder.write_string(json_string(diagnostic_level_name(issue.level)))
    builder.write_string(",\"code\":")
    builder.write_string(json_string(issue.code))
    builder.write_string(",\"message\":")
    builder.write_string(json_string(issue.message))
    builder.write_char('}')
  }
  builder.write_string("]}")
  builder.to_string()
}

///|
fn csv_escape(value : String) -> String {
  let needs_quotes = value.contains(",") ||
    value.contains("\"") ||
    value.contains("\n") ||
    value.contains("\r")
  if !needs_quotes {
    return value
  }
  let builder = StringBuilder::new()
  builder.write_char('"')
  for char in value {
    if char == '"' {
      builder.write_string("\"\"")
    } else {
      builder.write_char(char)
    }
  }
  builder.write_char('"')
  builder.to_string()
}

///|
/// Serialize assignment rows for spreadsheet analysis.
pub fn assignments_to_csv(values : Array[Assignment]) -> String {
  let builder = StringBuilder::new()
  builder.write_string(
    "visit_id,worker_id,start_minute,end_minute,travel_before_minutes,score,reasons\n",
  )
  for value in sort_schedule_assignments(values) {
    builder.write_string(csv_escape(value.visit_id))
    builder.write_char(',')
    builder.write_string(csv_escape(value.worker_id))
    builder.write_string(",\{value.start_minute},\{value.end_minute}")
    builder.write_string(",\{value.travel_before_minutes},\{value.score},")
    builder.write_string(csv_escape(value.reasons.join("; ")))
    builder.write_char('\n')
  }
  builder.to_string()
}

///|
/// Compact terminal summary.
pub fn schedule_summary(value : Schedule) -> String {
  let builder = StringBuilder::new()
  builder.write_string("NeighborRota schedule \{value.schema}\n")
  builder.write_string(
    "feasible=\{value.feasible}, assignments=\{value.assignments.length()}, unassigned=\{value.unassigned.length()}\n",
  )
  builder.write_string(score_summary(value.score))
  builder.write_char('\n')
  for assignment in sort_schedule_assignments(value.assignments) {
    builder.write_string(
      "- \{assignment.start_minute}-\{assignment.end_minute}  \{assignment.visit_id} -> \{assignment.worker_id}  travel=\{assignment.travel_before_minutes}\n",
    )
  }
  for missing in value.unassigned {
    builder.write_string(
      "! \{missing.visit_id} unassigned: \{missing.reason_codes.join(", ")}\n",
    )
  }
  builder.to_string()
}

///|
/// Human-oriented Markdown report.
pub fn schedule_to_markdown(value : Schedule) -> String {
  let builder = StringBuilder::new()
  builder.write_string("# NeighborRota Schedule Report\n\n")
  builder.write_string("- Schema: `\{value.schema}`\n")
  builder.write_string("- Feasible: **\{value.feasible}**\n")
  builder.write_string("- Score: `\{value.score.total}`\n")
  builder.write_string("- Assignments: \{value.assignments.length()}\n")
  builder.write_string("- Unassigned: \{value.unassigned.length()}\n\n")
  builder.write_string("## Assignments\n\n")
  builder.write_string("| Visit | Worker | Start | End | Travel |\n")
  builder.write_string("|---|---|---:|---:|---:|\n")
  for assignment in sort_schedule_assignments(value.assignments) {
    builder.write_string(
      "| \{assignment.visit_id} | \{assignment.worker_id} | \{assignment.start_minute} | \{assignment.end_minute} | \{assignment.travel_before_minutes} |\n",
    )
  }
  if value.unassigned.length() > 0 {
    builder.write_string("\n## Unassigned visits\n\n")
    for missing in value.unassigned {
      builder.write_string(
        "- **\{missing.visit_id}**: \{missing.reason_codes.join(", ")}\n",
      )
    }
  }
  builder.write_string("\n## Score breakdown\n\n`")
  builder.write_string(score_summary(value.score))
  builder.write_string("`\n")
  builder.to_string()
}

///|
/// Markdown impact report for an event-driven update.
pub fn reschedule_to_markdown(value : RescheduleReport) -> String {
  let builder = StringBuilder::new()
  builder.write_string("# NeighborRota Reschedule Report\n\n")
  builder.write_string("- ")
  builder.write_string(reschedule_summary(value))
  builder.write_string("\n\n## Changes\n\n")
  for change in value.changes {
    builder.write_string("- `\{assignment_change_name(change)}`: ")
    builder.write_string(describe_assignment_change(change))
    builder.write_char('\n')
  }
  builder.write_string("\n## Updated schedule\n\n")
  builder.write_string(schedule_to_markdown(value.updated))
  builder.to_string()
}