///|
priv suberror UnexpandError {
  UnexpandError(String)
}

///|
fn parse_stops(text : String) -> Array[Int] raise UnexpandError {
  if text == "" {
    raise UnexpandError("invalid tab size")
  }
  let stops : Array[Int] = []
  for part in text.split(",") {
    if part.trim() == "" {
      raise UnexpandError("invalid tab size")
    }
    let value = @string.parse_int(part.trim().to_owned()) catch {
      _ => raise UnexpandError("invalid tab size")
    }
    if value <= 0 || (stops.length() > 0 && value <= stops.last().unwrap_or(0)) {
      raise UnexpandError("tab sizes must be ascending")
    }
    stops.push(value)
  }
  if stops.is_empty() {
    raise UnexpandError("invalid tab size")
  }
  stops
}

///|
fn next_stop(column : Int, stops : Array[Int]) -> Int? {
  for stop in stops {
    if stop > column {
      return Some(stop)
    }
  }
  if stops.length() == 1 {
    let width = stops[0]
    return Some((column / width + 1) * width)
  }
  None
}

///|
fn emit_spaces_as_tabs(
  output : Array[Byte],
  start : Int,
  count : Int,
  stops : Array[Int],
) -> Int {
  let end = start + count
  let mut column = start
  while column < end {
    match next_stop(column, stops) {
      Some(stop) if stop <= end => {
        output.push(b'\t')
        column = stop
      }
      _ => {
        output.push(b' ')
        column += 1
      }
    }
  }
  column
}

///|
fn unexpand_bytes(
  data : Bytes,
  all_blanks : Bool,
  first_only : Bool,
  stops : Array[Int],
) -> Bytes {
  let output : Array[Byte] = []
  let mut column = 0
  let mut line_has_nonblank = false
  let pending_spaces : Array[Byte] = []
  fn flush_pending() {
    if pending_spaces.is_empty() {
      return
    }
    let eligible = if first_only {
      !line_has_nonblank
    } else {
      all_blanks || !line_has_nonblank
    }
    if eligible {
      column = emit_spaces_as_tabs(
        output,
        column,
        pending_spaces.length(),
        stops,
      )
    } else {
      for _ in pending_spaces {
        output.push(b' ')
        column += 1
      }
    }
    pending_spaces.clear()
  }
  for byte in data {
    if byte == b' ' {
      pending_spaces.push(byte)
    } else {
      flush_pending()
      if byte == b'\n' {
        output.push(byte)
        column = 0
        line_has_nonblank = false
      } else if byte == b'\t' {
        output.push(byte)
        column = match next_stop(column, stops) {
          Some(stop) => stop
          None => column + 1
        }
        // A tab is still part of the initial blank prefix.
      } else if byte == b'\b' {
        output.push(byte)
        if column > 0 {
          column -= 1
        }
      } else {
        output.push(byte)
        column += 1
        line_has_nonblank = true
      }
    }
  }
  flush_pending()
  Bytes::from_array(output)
}

///|
fn help_message() -> String {
  let message =
    #|Usage: unexpand [OPTION]... [FILE]...
    #|
    #|Convert spaces in each FILE to tabs, writing to standard output.
    #|With no FILE, or when FILE is -, read standard input.
    #|
    #|  -a, --all          convert all blanks, not just leading blanks
    #|      --first-only   convert only leading blank sequences
    #|  -t, --tabs=LIST    use comma-separated tab stops (implies --all)
    #|      --help         display this help and exit
  message
}

///|
async fn read_input(path : String) -> Bytes {
  if path == "-" {
    @stdio.stdin.read_all().binary()
  } else {
    @fs.read_file(path).binary()
  }
}

///|
async fn main {
  let parsed = @cli.parse(@env.args()[1:], [
    @cli.flag("all", short='a'),
    @cli.flag("first-only"),
    @cli.option("tabs", short='t'),
    @cli.flag("help"),
  ]) catch {
    @cli.CliError(option~, message~, ..) => {
      @stdio.stderr.write("unexpand: \{message}: '\{option}'\n")
      @sys.exit(2)
      return
    }
  }
  if parsed.contains("help") {
    @stdio.stdout.write(help_message() + "\n")
    return
  }
  let stops = parse_stops(parsed.last_value("tabs").unwrap_or("8")) catch {
    UnexpandError(message) => {
      @stdio.stderr.write("unexpand: \{message}\n")
      @sys.exit(2)
      return
    }
  }
  let all_blanks = parsed.contains("all") || parsed.contains("tabs")
  let sources = if parsed.operands.is_empty() {
    ["-"][:]
  } else {
    parsed.operands
  }
  let mut failed = false
  for path in sources {
    let data = read_input(path) catch {
      err => {
        @stdio.stderr.write("unexpand: \{path}: \{err}\n")
        failed = true
        continue
      }
    }
    @stdio.stdout.write(
      unexpand_bytes(data, all_blanks, parsed.contains("first-only"), stops),
    )
  }
  if failed {
    @sys.exit(1)
  }
}