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

///|
priv struct Parser {
  args : Array[String]
  mut pos : Int32
}

///|
fn Parser::peek(self : Parser) -> String? {
  self.args.get(self.pos)
}

///|
fn Parser::take(self : Parser) -> String raise TestError {
  guard self.args.get(self.pos) is Some(value) else {
    raise TestError("missing argument")
  }
  self.pos += 1
  value
}

///|
fn is_unary(op : String) -> Bool {
  op == "-n" ||
  op == "-z" ||
  op == "-e" ||
  op == "-a" ||
  op == "-f" ||
  op == "-d" ||
  op == "-L" ||
  op == "-h" ||
  op == "-p" ||
  op == "-S" ||
  op == "-b" ||
  op == "-c" ||
  op == "-s" ||
  op == "-N" ||
  op == "-r" ||
  op == "-w" ||
  op == "-x"
}

///|
fn is_binary(op : String) -> Bool {
  op == "=" ||
  op == "==" ||
  op == "!=" ||
  op == "<" ||
  op == ">" ||
  op == "-eq" ||
  op == "-ne" ||
  op == "-lt" ||
  op == "-le" ||
  op == "-gt" ||
  op == "-ge" ||
  op == "-nt" ||
  op == "-ot"
}

///|
async fn eval_unary(op : String, value : String) -> Bool {
  match op {
    "-n" => value != ""
    "-z" => value == ""
    _ if is_unary(op) => test_file(op, value)
    _ => raise TestError("unknown unary operator: '\{op}'")
  }
}

///|
fn parse_integer(text : String) -> Int64 raise TestError {
  let mut start = 0
  let mut end = text.length()
  while start < end &&
        text[start] is (' ' | '\t' | '\n' | '\r' | '\u000b' | '\u000c') {
    start += 1
  }
  while end > start &&
        text[end - 1] is (' ' | '\t' | '\n' | '\r' | '\u000b' | '\u000c') {
    end -= 1
  }
  let mut digits = start
  if digits < end && text[digits] is ('+' | '-') {
    digits += 1
  }
  guard digits < end else {
    raise TestError("integer expression expected: '\{text}'")
  }
  for i = digits; i < end; i = i + 1 {
    guard text[i] is ('0'..<='9') else {
      raise TestError("integer expression expected: '\{text}'")
    }
  }
  @string.parse_int64(text[start:end], base=10) catch {
    _ => raise TestError("integer expression expected: '\{text}'")
  }
}

///|
async fn optional_mtime(path : String) -> @fsops.FileTimestamp? {
  if path == "" {
    return None
  }
  try {
    guard test_lookup_path(path) is Some(path) else { return None }
    Some(@fsops.read_file_timestamp(path, ModificationTime))
  } catch {
    @os_error.OSError(_) => None
    error => raise error
  }
}

///|
async fn eval_binary(left : String, op : String, right : String) -> Bool {
  match op {
    "=" | "==" => left == right
    "!=" => left != right
    "<" => left < right
    ">" => left > right
    "-eq" => parse_integer(left) == parse_integer(right)
    "-ne" => parse_integer(left) != parse_integer(right)
    "-lt" => parse_integer(left) < parse_integer(right)
    "-le" => parse_integer(left) <= parse_integer(right)
    "-gt" => parse_integer(left) > parse_integer(right)
    "-ge" => parse_integer(left) >= parse_integer(right)
    "-nt" =>
      match (optional_mtime(left), optional_mtime(right)) {
        (Some(left), Some(right)) => @fsops.compare_timestamps(left, right) > 0
        (Some(_), None) => true
        _ => false
      }
    "-ot" =>
      match (optional_mtime(left), optional_mtime(right)) {
        (Some(left), Some(right)) => @fsops.compare_timestamps(left, right) < 0
        (None, Some(_)) => true
        _ => false
      }
    _ => raise TestError("unknown binary operator: '\{op}'")
  }
}

///|
async fn Parser::parse_primary(self : Parser) -> Bool {
  let token = self.take()
  if token == "(" {
    let mut count = 1
    while self.pos + count < self.args.length() &&
          self.args[self.pos + count] != ")" {
      if count == 4 {
        count = self.args.length() - self.pos
        break
      }
      count += 1
    }
    let value = self.evaluate_count(count)
    if self.take() != ")" {
      raise TestError("expected ')'")
    }
    return value
  }
  match self.peek() {
    Some(op) if self.pos + 1 < self.args.length() && is_binary(op) => {
      ignore(self.take())
      eval_binary(token, op, self.take())
    }
    _ if token.length() == 2 && token.has_prefix("-") =>
      eval_unary(token, self.take())
    _ => token != ""
  }
}

///|
async fn Parser::parse_not(self : Parser) -> Bool {
  if self.peek() == Some("!") {
    ignore(self.take())
    !self.parse_not()
  } else {
    self.parse_primary()
  }
}

///|
async fn Parser::parse_and(self : Parser) -> Bool {
  let mut value = self.parse_not()
  while self.peek() == Some("-a") {
    ignore(self.take())
    let right = self.parse_not()
    value = value && right
  }
  value
}

///|
async fn Parser::parse_or(self : Parser) -> Bool {
  let mut value = self.parse_and()
  while self.peek() == Some("-o") {
    ignore(self.take())
    let right = self.parse_and()
    value = value || right
  }
  value
}

///|
async fn main {
  let metadata_args = @env.args()[1:]
  if metadata_args.length() == 1 && metadata_args[0] == "--version" {
    @stdio.stdout.write("test 0.2.4\n")
    return
  }
  let args = @env.args()[1:].to_owned()
  if args == ["--help"] {
    @stdio.stdout.write(
      "Usage: test EXPRESSION\n      --help      display this help and exit\n      --version   display version and exit\nSupports string, integer, portable file-kind, access, size, and timestamp expressions.\nOne through four arguments have standalone test precedence; a lone nonempty operator-looking string is true.\nInteger operands are signed 64-bit decimal with optional sign and surrounding ASCII whitespace.\n-s reads followed target metadata without opening the file; unavailable metadata exits 2.\nUnary -a is a project alias for -e. Exit 0 for true, 1 for false, and 2 for errors.\n",
    )
    return
  }
  if args.is_empty() {
    @sys.exit(1)
    return
  }
  let parser : Parser = { args, pos: 0, }
  let result = parser.evaluate_count(args.length()) catch {
    TestError(message) => {
      @stdio.stderr.write("test: \{message}\n")
      @sys.exit(2)
      return
    }
    err => {
      @stdio.stderr.write("test: \{err}\n")
      @sys.exit(2)
      return
    }
  }
  if parser.pos != args.length() {
    @stdio.stderr.write("test: unexpected argument: '\{args[parser.pos]}'\n")
    @sys.exit(2)
    return
  }
  if !result {
    @sys.exit(1)
  }
}