///|
/// One lexical CSV row before domain conversion.
pub(all) struct CsvRow {
  line : Int
  fields : Array[String]
} derive(Debug, Eq)

///|
/// Parsed CSV document with non-fatal syntax diagnostics.
pub(all) struct CsvDocument {
  rows : Array[CsvRow]
  diagnostics : Array[Diagnostic]
} derive(Debug, Eq)

///|
/// Worker import result. Valid rows remain available when other rows fail.
pub(all) struct WorkerBatch {
  workers : Array[Worker]
  diagnostics : Array[Diagnostic]
} derive(Debug, Eq)

///|
/// Visit import result. Valid rows remain available when other rows fail.
pub(all) struct VisitBatch {
  visits : Array[Visit]
  diagnostics : Array[Diagnostic]
} derive(Debug, Eq)

///|
fn finish_csv_field(fields : Array[String], builder : StringBuilder) -> Unit {
  fields.push(builder.to_string())
  builder.reset()
}

///|
fn finish_csv_row(
  rows : Array[CsvRow],
  fields : Array[String],
  builder : StringBuilder,
  line : Int,
) -> Unit {
  finish_csv_field(fields, builder)
  if fields.length() == 1 && fields[0].trim().is_empty() {
    fields.clear()
    return
  }
  rows.push({ line, fields: copy_array(fields) })
  fields.clear()
}

///|
/// Parse commas, quoted cells, doubled quotes and LF/CRLF line endings.
pub fn parse_csv_document(input : String) -> CsvDocument {
  let rows : Array[CsvRow] = []
  let diagnostics : Array[Diagnostic] = []
  let fields : Array[String] = []
  let field = StringBuilder::new()
  let characters : Array[Char] = []
  for character in input {
    characters.push(character)
  }
  let mut index = 0
  let mut line = 1
  let mut row_line = 1
  let mut quoted = false
  let mut after_quote = false
  while index < characters.length() {
    let character = characters[index]
    if quoted {
      if character == '"' {
        if index + 1 < characters.length() && characters[index + 1] == '"' {
          field.write_char('"')
          index = index + 2
          continue
        }
        quoted = false
        after_quote = true
      } else {
        field.write_char(character)
        if character == '\n' {
          line = line + 1
        }
      }
      index = index + 1
      continue
    }
    if after_quote {
      if character == ',' {
        finish_csv_field(fields, field)
        after_quote = false
      } else if character == '\n' {
        finish_csv_row(rows, fields, field, row_line)
        after_quote = false
        line = line + 1
        row_line = line
      } else if character == '\r' || character == ' ' || character == '\t' {
        ()
      } else {
        diagnostics.push(
          warning_diagnostic(
            "csv.content_after_quote", "unexpected content follows closing quote",
          ).for_entity("line-{line}"),
        )
        field.write_char(character)
        after_quote = false
      }
      index = index + 1
      continue
    }
    if character == '"' {
      if field.is_empty() {
        quoted = true
      } else {
        diagnostics.push(
          warning_diagnostic(
            "csv.quote_in_plain_field", "quote occurred inside an unquoted field",
          ).for_entity("line-{line}"),
        )
        field.write_char(character)
      }
    } else if character == ',' {
      finish_csv_field(fields, field)
    } else if character == '\n' {
      finish_csv_row(rows, fields, field, row_line)
      line = line + 1
      row_line = line
    } else if character != '\r' {
      field.write_char(character)
    }
    index = index + 1
  }
  if quoted {
    diagnostics.push(
      error_diagnostic(
        "csv.unclosed_quote", "quoted field was not closed before end of input",
      ).for_entity("line-{row_line}"),
    )
  }
  if !field.is_empty() || fields.length() > 0 || after_quote {
    finish_csv_row(rows, fields, field, row_line)
  }
  { rows, diagnostics }
}

///|
fn strip_bom(value : String) -> String {
  let characters : Array[Char] = []
  for character in value {
    characters.push(character)
  }
  if characters.length() > 0 && characters[0] == '\u{FEFF}' {
    let builder = StringBuilder::new()
    for index = 1; index < characters.length(); index = index + 1 {
      builder.write_char(characters[index])
    }
    builder.to_string()
  } else {
    value
  }
}

///|
fn normalize_header(value : String) -> String {
  strip_bom(value).trim().to_owned().to_lower()
}

///|
fn header_index(headers : Array[String], name : String) -> Int? {
  for index, header in headers {
    if normalize_header(header) == name {
      return Some(index)
    }
  }
  None
}

///|
fn required_header(
  headers : Array[String],
  name : String,
  diagnostics : Array[Diagnostic],
) -> Int? {
  match header_index(headers, name) {
    Some(index) => Some(index)
    None => {
      diagnostics.push(
        error_diagnostic("csv.missing_header", "required CSV header is missing").at_field(
          name,
        ),
      )
      None
    }
  }
}

///|
fn row_value(row : CsvRow, index : Int?) -> String {
  match index {
    Some(value) =>
      if value >= 0 && value < row.fields.length() {
        row.fields[value].trim().to_owned()
      } else {
        ""
      }
    None => ""
  }
}

///|
/// Parse a base-10 integer without accepting decimal or exponent notation.
pub fn parse_csv_int(input : String) -> Int? {
  let value = input.trim()
  if value.is_empty() {
    return None
  }
  let characters : Array[Char] = []
  for character in value {
    characters.push(character)
  }
  let mut index = 0
  let mut sign = 1
  if characters[0] == '-' {
    sign = -1
    index = 1
  } else if characters[0] == '+' {
    index = 1
  }
  if index >= characters.length() {
    return None
  }
  let mut result = 0
  while index < characters.length() {
    let character = characters[index]
    if character < '0' || character > '9' {
      return None
    }
    result = result * 10 + character.to_int() - '0'.to_int()
    index = index + 1
  }
  Some(result * sign)
}

///|
/// Parse common CSV boolean spellings.
pub fn parse_csv_bool(input : String) -> Bool? {
  match input.trim().to_lower() {
    "true" | "yes" | "y" | "1" => Some(true)
    "false" | "no" | "n" | "0" => Some(false)
    _ => None
  }
}

///|
/// Split a pipe-delimited list, trimming and discarding empty items.
pub fn parse_pipe_list(input : String) -> Array[String] {
  let values : Array[String] = []
  for part in input.split("|") {
    let value = part.trim().to_owned()
    if !value.is_empty() {
      values.push(value)
    }
  }
  values
}

///|
fn parse_skill_token(
  token : String,
  entity_id : String,
  diagnostics : Array[Diagnostic],
) -> Skill? {
  let parts = token.split(":").collect()
  if parts.length() != 2 {
    diagnostics.push(
      error_diagnostic("csv.invalid_skill", "skill must use name:level syntax")
      .for_entity(entity_id)
      .at_field(token),
    )
    return None
  }
  let name = parts[0].trim().to_owned()
  match parse_csv_int(parts[1].to_owned()) {
    None => {
      diagnostics.push(
        error_diagnostic(
          "csv.invalid_skill_level", "skill level is not an integer",
        )
        .for_entity(entity_id)
        .at_field(token),
      )
      None
    }
    Some(level) => {
      let value = skill(name, level)
      let issues = validate_skill(value, entity_id)
      diagnostics.append(issues)
      if diagnostics_have_errors(issues) {
        None
      } else {
        Some(value)
      }
    }
  }
}

///|
/// Parse `name:level|name:level` skill declarations.
pub fn parse_skills(
  input : String,
  entity_id : String,
) -> (Array[Skill], Array[Diagnostic]) {
  let skills : Array[Skill] = []
  let diagnostics : Array[Diagnostic] = []
  for token in parse_pipe_list(input) {
    match parse_skill_token(token, entity_id, diagnostics) {
      Some(value) => skills.push(value)
      None => ()
    }
  }
  (skills, diagnostics)
}

///|
fn parse_window_token(
  token : String,
  entity_id : String,
  diagnostics : Array[Diagnostic],
) -> TimeWindow? {
  let parts = token.split("-").collect()
  if parts.length() != 2 {
    diagnostics.push(
      error_diagnostic(
        "csv.invalid_window", "availability must use start-end minute syntax",
      )
      .for_entity(entity_id)
      .at_field(token),
    )
    return None
  }
  match
    (parse_csv_int(parts[0].to_owned()), parse_csv_int(parts[1].to_owned())) {
    (Some(start), Some(end)) => {
      let value = time_window(start, end)
      let issues = validate_time_window(value, entity_id, "availability")
      diagnostics.append(issues)
      if diagnostics_have_errors(issues) {
        None
      } else {
        Some(value)
      }
    }
    _ => {
      diagnostics.push(
        error_diagnostic(
          "csv.invalid_window_minutes", "availability bounds must be integers",
        )
        .for_entity(entity_id)
        .at_field(token),
      )
      None
    }
  }
}

///|
/// Parse `start-end|start-end` availability declarations.
pub fn parse_availability(
  input : String,
  entity_id : String,
) -> (Array[TimeWindow], Array[Diagnostic]) {
  let windows : Array[TimeWindow] = []
  let diagnostics : Array[Diagnostic] = []
  for token in parse_pipe_list(input) {
    match parse_window_token(token, entity_id, diagnostics) {
      Some(value) => windows.push(value)
      None => ()
    }
  }
  (windows, diagnostics)
}

///|
fn parse_worker_kind_value(input : String) -> WorkerKind? {
  match input.trim().to_lower() {
    "volunteer" => Some(Volunteer)
    "care_worker" | "care-worker" | "careworker" => Some(CareWorker)
    "coordinator" => Some(Coordinator)
    _ => None
  }
}

///|
fn parse_priority_value(input : String) -> Priority? {
  match input.trim().to_lower() {
    "low" => Some(Low)
    "normal" => Some(Normal)
    "high" => Some(High)
    "critical" => Some(Critical)
    _ => None
  }
}

///|
fn required_int_field(
  row : CsvRow,
  index : Int?,
  entity_id : String,
  field : String,
  diagnostics : Array[Diagnostic],
) -> Int? {
  match parse_csv_int(row_value(row, index)) {
    Some(value) => Some(value)
    None => {
      diagnostics.push(
        error_diagnostic(
          "csv.invalid_integer", "required integer field is missing or malformed",
        )
        .for_entity(entity_id)
        .at_field(field),
      )
      None
    }
  }
}

///|
fn optional_int_field(row : CsvRow, index : Int?, fallback : Int) -> Int {
  match parse_csv_int(row_value(row, index)) {
    Some(value) => value
    None => fallback
  }
}

///|
/// Parse the documented worker CSV format.
pub fn parse_workers_csv(input : String) -> WorkerBatch {
  let document = parse_csv_document(input)
  let diagnostics = copy_array(document.diagnostics)
  let workers : Array[Worker] = []
  if document.rows.length() == 0 {
    diagnostics.push(error_diagnostic("csv.empty", "worker CSV is empty"))
    return { workers, diagnostics }
  }
  let headers = document.rows[0].fields
  let id_col = required_header(headers, "id", diagnostics)
  let name_col = required_header(headers, "display_name", diagnostics)
  let kind_col = required_header(headers, "kind", diagnostics)
  let home_id_col = required_header(headers, "home_id", diagnostics)
  let home_x_col = required_header(headers, "home_x", diagnostics)
  let home_y_col = required_header(headers, "home_y", diagnostics)
  let zone_col = required_header(headers, "zone", diagnostics)
  let skills_col = required_header(headers, "skills", diagnostics)
  let availability_col = required_header(headers, "availability", diagnostics)
  let max_minutes_col = header_index(headers, "max_minutes")
  let max_visits_col = header_index(headers, "max_visits")
  let min_break_col = header_index(headers, "min_break_minutes")
  let preferred_zones_col = header_index(headers, "preferred_zones")
  let unavailable_col = header_index(headers, "unavailable")
  if diagnostics_have_errors(diagnostics) {
    return { workers, diagnostics }
  }
  for row_index = 1
      row_index < document.rows.length()
      row_index = row_index + 1 {
    let row = document.rows[row_index]
    let id = row_value(row, id_col)
    let row_issues : Array[Diagnostic] = []
    if id.is_empty() {
      row_issues.push(
        error_diagnostic("csv.worker_empty_id", "worker id is empty").for_entity(
          "line-{row.line}",
        ),
      )
    }
    let kind = parse_worker_kind_value(row_value(row, kind_col))
    if kind is None {
      row_issues.push(
        error_diagnostic("csv.invalid_worker_kind", "worker kind is unknown")
        .for_entity(id)
        .at_field("kind"),
      )
    }
    let x = required_int_field(row, home_x_col, id, "home_x", row_issues)
    let y = required_int_field(row, home_y_col, id, "home_y", row_issues)
    let (skills, skill_issues) = parse_skills(row_value(row, skills_col), id)
    row_issues.append(skill_issues)
    let (availability, availability_issues) = parse_availability(
      row_value(row, availability_col),
      id,
    )
    row_issues.append(availability_issues)
    let unavailable = match parse_csv_bool(row_value(row, unavailable_col)) {
      Some(value) => value
      None => false
    }
    match (kind, x, y) {
      (Some(worker_kind), Some(home_x), Some(home_y)) => {
        let value : Worker = {
          id,
          display_name: row_value(row, name_col),
          kind: worker_kind,
          skills,
          availability,
          home: location(
            row_value(row, home_id_col),
            home_x,
            home_y,
            row_value(row, zone_col),
          ),
          max_minutes: optional_int_field(row, max_minutes_col, 480),
          max_visits: optional_int_field(row, max_visits_col, 8),
          min_break_minutes: optional_int_field(row, min_break_col, 10),
          preferred_zones: parse_pipe_list(row_value(row, preferred_zones_col)),
          unavailable,
        }
        row_issues.append(validate_worker(value))
        if !diagnostics_have_errors(row_issues) {
          workers.push(value)
        }
      }
      _ => ()
    }
    diagnostics.append(row_issues)
  }
  { workers, diagnostics }
}

///|
/// Parse the documented visit CSV format.
pub fn parse_visits_csv(input : String) -> VisitBatch {
  let document = parse_csv_document(input)
  let diagnostics = copy_array(document.diagnostics)
  let visits : Array[Visit] = []
  if document.rows.length() == 0 {
    diagnostics.push(error_diagnostic("csv.empty", "visit CSV is empty"))
    return { visits, diagnostics }
  }
  let headers = document.rows[0].fields
  let id_col = required_header(headers, "id", diagnostics)
  let recipient_col = required_header(headers, "recipient_id", diagnostics)
  let title_col = required_header(headers, "title", diagnostics)
  let location_col = required_header(headers, "location_id", diagnostics)
  let x_col = required_header(headers, "x", diagnostics)
  let y_col = required_header(headers, "y", diagnostics)
  let zone_col = required_header(headers, "zone", diagnostics)
  let start_col = required_header(headers, "window_start", diagnostics)
  let end_col = required_header(headers, "window_end", diagnostics)
  let duration_col = required_header(headers, "duration_minutes", diagnostics)
  let skills_col = required_header(headers, "required_skills", diagnostics)
  let priority_col = required_header(headers, "priority", diagnostics)
  let preferred_col = header_index(headers, "preferred_workers")
  let forbidden_col = header_index(headers, "forbidden_workers")
  let continuity_col = header_index(headers, "continuity_group")
  let required_col = header_index(headers, "required")
  if diagnostics_have_errors(diagnostics) {
    return { visits, diagnostics }
  }
  for row_index = 1
      row_index < document.rows.length()
      row_index = row_index + 1 {
    let row = document.rows[row_index]
    let id = row_value(row, id_col)
    let row_issues : Array[Diagnostic] = []
    let x = required_int_field(row, x_col, id, "x", row_issues)
    let y = required_int_field(row, y_col, id, "y", row_issues)
    let start = required_int_field(
      row, start_col, id, "window_start", row_issues,
    )
    let end = required_int_field(row, end_col, id, "window_end", row_issues)
    let duration = required_int_field(
      row, duration_col, id, "duration_minutes", row_issues,
    )
    let priority = parse_priority_value(row_value(row, priority_col))
    if priority is None {
      row_issues.push(
        error_diagnostic("csv.invalid_priority", "visit priority is unknown")
        .for_entity(id)
        .at_field("priority"),
      )
    }
    let (skills, skill_issues) = parse_skills(row_value(row, skills_col), id)
    row_issues.append(skill_issues)
    match (x, y, start, end, duration, priority) {
      (
        Some(place_x),
        Some(place_y),
        Some(window_start),
        Some(window_end),
        Some(service_minutes),
        Some(visit_priority),
      ) => {
        let continuity_value = row_value(row, continuity_col)
        let required_value = match
          parse_csv_bool(row_value(row, required_col)) {
          Some(value) => value
          None => true
        }
        let value : Visit = {
          id,
          recipient_id: row_value(row, recipient_col),
          title: row_value(row, title_col),
          location: location(
            row_value(row, location_col),
            place_x,
            place_y,
            row_value(row, zone_col),
          ),
          window: time_window(window_start, window_end),
          duration_minutes: service_minutes,
          required_skills: skills,
          priority: visit_priority,
          preferred_worker_ids: parse_pipe_list(row_value(row, preferred_col)),
          forbidden_worker_ids: parse_pipe_list(row_value(row, forbidden_col)),
          continuity_group: if continuity_value.is_empty() {
            None
          } else {
            Some(continuity_value)
          },
          required: required_value,
        }
        row_issues.append(validate_visit(value))
        if !diagnostics_have_errors(row_issues) {
          visits.push(value)
        }
      }
      _ => ()
    }
    diagnostics.append(row_issues)
  }
  { visits, diagnostics }
}

///|
/// Parse both input documents and construct a validated request.
pub fn request_from_csv(
  workers_csv : String,
  visits_csv : String,
) -> (ScheduleRequest, Array[Diagnostic]) {
  let worker_batch = parse_workers_csv(workers_csv)
  let visit_batch = parse_visits_csv(visits_csv)
  let diagnostics = copy_array(worker_batch.diagnostics)
  diagnostics.append(visit_batch.diagnostics)
  let request = schedule_request(worker_batch.workers, visit_batch.visits)
  if !diagnostics_have_errors(diagnostics) {
    diagnostics.append(validate_request(request))
  }
  (request, diagnostics)
}