///|
pub struct CsvIssue {
  source : String
  line : Int
  column : Int
  code : String
  message : String
} derive(Eq, Show, ToJson)

///|
pub fn CsvIssue::new(
  source : String,
  line : Int,
  column : Int,
  code : String,
  message : String,
) -> CsvIssue {
  { source, line, column, code, message }
}

///|
pub(all) enum ProblemParseResult {
  Parsed(PlanningProblem)
  ParseFailed(Array[CsvIssue])
} derive(Eq, Show, ToJson)

///|
fn split_owned(text : String, separator : String) -> Array[String] {
  [
    for part in text.split(separator) => part.to_string()
  ]
}

///|
fn nonempty_lines(text : String) -> Array[String] {
  let lines : Array[String] = []
  for raw in text.split("\n") {
    let line = raw.to_string().trim().to_string()
    if line.length() > 0 && !line.starts_with("#") {
      lines.push(line)
    }
  }
  lines
}

///|
fn parse_unsigned(text : String) -> Int? {
  let value = text.trim()
  if value.length() == 0 {
    return None
  }
  let mut result = 0
  for ch in value {
    if ch < '0' || ch > '9' {
      return None
    }
    result = result * 10 + ch.to_int() - '0'.to_int()
  }
  Some(result)
}

///|
fn parse_boolean(text : String) -> Bool? {
  match text.trim().to_lower() {
    "true" | "yes" | "1" => Some(true)
    "false" | "no" | "0" => Some(false)
    _ => None
  }
}

///|
fn parse_skill_level(text : String) -> SkillLevel? {
  match text.trim().to_lower() {
    "1" | "beginner" => Some(Beginner)
    "2" | "qualified" => Some(Qualified)
    "3" | "advanced" => Some(Advanced)
    "4" | "expert" => Some(Expert)
    _ => None
  }
}

///|
fn parse_shift_kind(text : String) -> ShiftKind {
  match text.trim().to_lower() {
    "morning" => Morning
    "day" => Day
    "evening" => Evening
    "night" => Night
    "oncall" | "on-call" => OnCall
    other => Custom(other.to_string())
  }
}

///|
fn expected_columns(
  source : String,
  line : Int,
  actual : Int,
  expected : Int,
) -> CsvIssue {
  CsvIssue::new(
    source,
    line,
    0,
    "column_count",
    "expected " +
    expected.to_string() +
    " columns but found " +
    actual.to_string(),
  )
}

///|
fn parse_number_field(
  source : String,
  line : Int,
  column : Int,
  name : String,
  text : String,
  issues : Array[CsvIssue],
) -> Int {
  match parse_unsigned(text) {
    Some(value) => value
    None => {
      issues.push(
        CsvIssue::new(
          source,
          line,
          column,
          "invalid_number",
          name + " must be a non-negative integer",
        ),
      )
      0
    }
  }
}

///|
fn parse_worker_skills(
  text : String,
  source : String,
  line : Int,
  issues : Array[CsvIssue],
) -> Array[WorkerSkill] {
  let result : Array[WorkerSkill] = []
  if text.trim().length() == 0 {
    return result
  }
  for token in text.split("|") {
    let parts = split_owned(token.to_string(), ":")
    if parts.length() != 2 {
      issues.push(
        CsvIssue::new(
          source, line, 7, "invalid_worker_skill", "skill must use skill-id:level syntax",
        ),
      )
    } else {
      match parse_skill_level(parts[1]) {
        Some(level) =>
          result.push(WorkerSkill::new(parts[0].trim().to_string(), level))
        None =>
          issues.push(
            CsvIssue::new(
              source, line, 7, "invalid_skill_level", "skill level must be beginner, qualified, advanced, expert, or 1-4",
            ),
          )
      }
    }
  }
  result
}

///|
fn parse_requirements(
  text : String,
  source : String,
  line : Int,
  issues : Array[CsvIssue],
) -> Array[SkillRequirement] {
  let result : Array[SkillRequirement] = []
  if text.trim().length() == 0 {
    return result
  }
  for token in text.split("|") {
    let parts = split_owned(token.to_string(), ":")
    if parts.length() != 3 {
      issues.push(
        CsvIssue::new(
          source, line, 8, "invalid_requirement", "requirement must use skill-id:level:count syntax",
        ),
      )
    } else {
      let level = parse_skill_level(parts[1])
      let count = parse_unsigned(parts[2])
      match (level, count) {
        (Some(level), Some(count)) if count > 0 =>
          result.push(
            SkillRequirement::new(parts[0].trim().to_string(), level, count),
          )
        _ =>
          issues.push(
            CsvIssue::new(
              source, line, 8, "invalid_requirement_value", "requirement level or count is invalid",
            ),
          )
      }
    }
  }
  result
}

///|
fn parse_workers_csv(text : String, issues : Array[CsvIssue]) -> Array[Worker] {
  let workers : Array[Worker] = []
  let lines = nonempty_lines(text)
  if lines.length() == 0 {
    issues.push(
      CsvIssue::new("workers", 0, 0, "empty_file", "workers CSV is empty"),
    )
    return workers
  }
  for index = 1; index < lines.length(); index = index + 1 {
    let line_number = index + 1
    let fields = split_owned(lines[index], ",")
    if fields.length() != 8 {
      issues.push(expected_columns("workers", line_number, fields.length(), 8))
      continue
    }
    let min_minutes = parse_number_field(
      "workers",
      line_number,
      2,
      "min_minutes",
      fields[2],
      issues,
    )
    let target_minutes = parse_number_field(
      "workers",
      line_number,
      3,
      "target_minutes",
      fields[3],
      issues,
    )
    let max_minutes = parse_number_field(
      "workers",
      line_number,
      4,
      "max_minutes",
      fields[4],
      issues,
    )
    let consecutive = parse_number_field(
      "workers",
      line_number,
      5,
      "max_consecutive",
      fields[5],
      issues,
    )
    let rest = parse_number_field(
      "workers",
      line_number,
      6,
      "min_rest",
      fields[6],
      issues,
    )
    let mut worker = Worker::new(
        fields[0].trim().to_string(),
        fields[1].trim().to_string(),
      )
      .with_limits(min_minutes, target_minutes, max_minutes)
      .with_rest_rules(consecutive, rest)
    for skill in parse_worker_skills(fields[7], "workers", line_number, issues) {
      worker = worker.add_skill(skill)
    }
    workers.push(worker)
  }
  workers
}

///|
fn parse_shifts_csv(text : String, issues : Array[CsvIssue]) -> Array[Shift] {
  let shifts : Array[Shift] = []
  let lines = nonempty_lines(text)
  if lines.length() == 0 {
    issues.push(
      CsvIssue::new("shifts", 0, 0, "empty_file", "shifts CSV is empty"),
    )
    return shifts
  }
  for index = 1; index < lines.length(); index = index + 1 {
    let line_number = index + 1
    let fields = split_owned(lines[index], ",")
    if fields.length() != 9 {
      issues.push(expected_columns("shifts", line_number, fields.length(), 9))
      continue
    }
    let start = parse_number_field(
      "shifts",
      line_number,
      2,
      "start",
      fields[2],
      issues,
    )
    let end = parse_number_field(
      "shifts",
      line_number,
      3,
      "end",
      fields[3],
      issues,
    )
    let required = parse_number_field(
      "shifts",
      line_number,
      4,
      "required",
      fields[4],
      issues,
    )
    let preferred = parse_number_field(
      "shifts",
      line_number,
      5,
      "preferred",
      fields[5],
      issues,
    )
    let weekend = match parse_boolean(fields[7]) {
      Some(value) => value
      None => {
        issues.push(
          CsvIssue::new(
            "shifts", line_number, 7, "invalid_boolean", "weekend must be true or false",
          ),
        )
        false
      }
    }
    let mut shift = Shift::new(
        fields[0].trim().to_string(),
        fields[1].trim().to_string(),
        start,
        end,
        required,
      )
      .with_preferred_workers(preferred)
      .with_kind(parse_shift_kind(fields[6]))
      .with_weekend(weekend)
    for
      requirement in parse_requirements(
        fields[8],
        "shifts",
        line_number,
        issues,
      ) {
      shift = shift.add_requirement(requirement)
    }
    shifts.push(shift)
  }
  shifts
}

///|
fn discover_skills(
  workers : Array[Worker],
  shifts : Array[Shift],
) -> Array[Skill] {
  let ids : Array[String] = []
  for worker in workers {
    for skill in worker.skills {
      if !ids.contains(skill.skill_id) {
        ids.push(skill.skill_id)
      }
    }
  }
  for shift in shifts {
    for requirement in shift.requirements {
      if !ids.contains(requirement.skill_id) {
        ids.push(requirement.skill_id)
      }
    }
  }
  let skills : Array[Skill] = []
  for id in ids {
    skills.push(Skill::new(id, id))
  }
  skills
}

///|
pub fn parse_problem_csv(
  name : String,
  workers_csv : String,
  shifts_csv : String,
) -> ProblemParseResult {
  let issues : Array[CsvIssue] = []
  let workers = parse_workers_csv(workers_csv, issues)
  let shifts = parse_shifts_csv(shifts_csv, issues)
  if issues.length() > 0 {
    return ParseFailed(issues)
  }
  let mut problem = PlanningProblem::new(name)
  for skill in discover_skills(workers, shifts) {
    problem = problem.add_skill(skill)
  }
  for worker in workers {
    problem = problem.add_worker(worker)
  }
  for shift in shifts {
    problem = problem.add_shift(shift)
  }
  let validation = problem.validate()
  if diagnostics_have_errors(validation) {
    for diagnostic in validation {
      issues.push(
        CsvIssue::new("problem", 0, 0, diagnostic.code, diagnostic.message),
      )
    }
    ParseFailed(issues)
  } else {
    Parsed(problem)
  }
}

///|
pub fn workers_csv_template() -> String {
  "id,name,min_minutes,target_minutes,max_minutes,max_consecutive,min_rest,skills\n" +
  "ada,Ada,0,960,1440,5,600,first-aid:4|lead:3\n" +
  "grace,Grace,0,960,1440,5,600,first-aid:2\n"
}

///|
pub fn shifts_csv_template() -> String {
  "id,label,start,end,required,preferred,kind,weekend,requirements\n" +
  "day-1,Day 1,480,960,1,1,day,false,first-aid:2:1\n" +
  "day-2,Day 2,1920,2400,1,1,day,false,\n"
}