///|
priv enum ErrorMode {
  Traditional
  Warn
  WarnNoPipe
  Exit
  ExitNoPipe
}

///|
priv struct Sink {
  name : String
  writer : &@io.Writer
  mut active : Bool
}

///|
fn pipe_error(error : Error) -> Bool {
  match error {
    @io.PipeClosed => true
    @os_error.OSError(code, ..) =>
      if @async.platform is Windows {
        code == 109 || code == 232
      } else {
        code == 32
      }
    _ => false
  }
}

///|
fn error_text(error : Error) -> String {
  if pipe_error(error) {
    return "Broken pipe"
  }
  match error {
    @os_error.OSError(_) as error if error.is_ENOENT() =>
      "No such file or directory"
    @os_error.OSError(_) as error if error.is_ENOTDIR() => "Not a directory"
    @os_error.OSError(_) as error if error.is_EACCES() => "Permission denied"
    @os_error.OSError(code, ..) if !(@async.platform is Windows) =>
      match code {
        9 => "Bad file descriptor"
        21 => "Is a directory"
        28 => "No space left on device"
        _ => error.to_string()
      }
    _ => error.to_string()
  }
}

///|
async fn open_error_text(path : String, error : Error) -> String {
  let kind = @fs.kind(path) catch { _ => Unknown }
  if kind is Directory {
    "Is a directory"
  } else {
    error_text(error)
  }
}

///|
fn diagnostic_name(name : String) -> String {
  if !name.is_empty() &&
    name
    .iter()
    .all(c => {
      c
      is ('a'..<='z'
      | 'A'..<='Z'
      | '0'..<='9'
      | '_'
      | '-'
      | '.'
      | '/'
      | ':'
      | '+')
    }) {
    name
  } else {
    "'" + name.replace_all(old="'", new="'\\''") + "'"
  }
}

///|
fn fatal_mode(mode : ErrorMode) -> Bool {
  mode is (Exit | ExitNoPipe)
}

///|
fn ignores_pipe(mode : ErrorMode) -> Bool {
  mode is (WarnNoPipe | ExitNoPipe)
}

///|
fn mode_from_name(value : String) -> ErrorMode? {
  let options = [
    ("warn", Warn),
    ("warn-nopipe", WarnNoPipe),
    ("exit", Exit),
    ("exit-nopipe", ExitNoPipe),
  ]
  for (name, mode) in options {
    if value == name {
      return Some(mode)
    }
  }
  let matching = options.filter(pair => pair.0.has_prefix(value))
  if matching.length() == 1 {
    Some(matching[0].1)
  } else {
    None
  }
}

///|
fn optional_output_error(args : ArrayView[String]) -> Array[String] {
  let result : Array[String] = []
  let mut literal = false
  for arg in args {
    result.push(
      if !literal && arg == "--output-error" {
        "--output-error=warn-nopipe"
      } else {
        arg
      },
    )
    if arg == "--" {
      literal = true
    }
  }
  result
}

///|
async fn transfer(
  input : &@io.Reader,
  outputs : Array[Sink],
  mode : ErrorMode,
) -> Int32 {
  let mut failed = false
  while outputs.any(output => output.active) {
    let chunk = input.read_some(max_len=65536) catch {
      error => {
        @stdio.stderr.write("tee: read error: \{error_text(error)}\n")
        return 1
      }
    }
    guard chunk is Some(chunk) else { break }
    for output in outputs {
      if !output.active {
        continue
      }
      output.writer.write(chunk) catch {
        error => {
          output.active = false
          if pipe_error(error) {
            if mode is Traditional {
              @stdio.stderr.write(
                "tee: unsupported capability: SIGPIPE termination\n",
              )
              return 2
            }
            if ignores_pipe(mode) {
              continue
            }
          }
          @stdio.stderr.write(
            "tee: \{diagnostic_name(output.name)}: \{error_text(error)}\n",
          )
          failed = true
          if fatal_mode(mode) {
            return 1
          }
        }
      }
    }
  }
  if failed {
    1
  } else {
    0
  }
}

///|
async fn main {
  let parsed = @cli.parse(optional_output_error(@env.args()[1:]), [
    @cli.flag("append", short='a'),
    @cli.option("output-error"),
    @cli.flag("output-error", short='p'),
    @cli.flag("help"),
    @cli.flag("version"),
  ]) catch {
    @cli.CliError(option~, message~, ..) => {
      @stdio.stderr.write("tee: \{message}: '\{option}'\n")
      @sys.exit(2)
      return
    }
  }
  if parsed.contains("help") {
    @stdio.stdout.write(
      "Usage: tee [-ap] [--output-error[=MODE]] [FILE...]\n" +
      "  -a, --append             append instead of truncating output files\n" +
      "  -p                       ignore broken output pipes; continue other sinks\n" +
      "      --output-error[=MODE] warn, warn-nopipe, exit, exit-nopipe\n" +
      "      --help               display this help and exit\n" +
      "      --version            display version and exit\n",
    )
    return
  }
  if parsed.contains("version") {
    @stdio.stdout.write("tee 0.2.3\n")
    return
  }
  let mut mode = Traditional
  for occurrence in parsed.occurrences.get("output-error").unwrap_or([]) {
    if occurrence is Some(value) {
      mode = match mode_from_name(value) {
        Some(mode) => mode
        None => {
          let ambiguous = ["warn", "warn-nopipe", "exit", "exit-nopipe"]
            .filter(name => name.has_prefix(value))
            .length() >
            1
          @stdio.stderr.write(
            "tee: \{if ambiguous { "ambiguous" } else { "invalid" }} argument '\{value}' for '--output-error'\n",
          )
          @stdio.stderr.write(
            "Valid arguments are:\n  - 'warn'\n  - 'warn-nopipe'\n  - 'exit'\n  - 'exit-nopipe'\nTry 'tee --help' for more information.\n",
          )
          @sys.exit(1)
          return
        }
      }
    } else {
      mode = WarnNoPipe
    }
  }
  let files : Array[@fs.File] = []
  defer (for file in files {
    file.close()
  })
  let outputs : Array[Sink] = [
    { name: "standard output", writer: @stdio.stdout, active: true, },
  ]
  let mut opening_failed = false
  for path in parsed.operands {
    let file = @fsops.open_output(
      path,
      if parsed.contains("append") {
        AppendOrCreate
      } else {
        CreateOrTruncate
      },
    ) catch {
      error => {
        @stdio.stderr.write(
          "tee: \{diagnostic_name(path)}: \{open_error_text(path, error)}\n",
        )
        opening_failed = true
        if fatal_mode(mode) {
          @sys.exit(1)
          return
        }
        continue
      }
    }
    files.push(file)
    outputs.push({ name: path, writer: file, active: true, })
  }
  let status = transfer(@stdio.stdin, outputs, mode)
  if status != 0 || opening_failed {
    @sys.exit(if status == 2 { 2 } else { 1 })
  }
}