///|
fn[A] die(message : String) -> A {
  @miniio.stderr.write_text("error: \{message}\n") catch {
    _ => ()
  }
  @miniio.exit(1)
  panic()
}

///|
fn path_join(base : String, child : String) -> String {
  @path.Path::join(base, child).to_string()
}

///|
fn normalize_path(path : String) -> String {
  @path.Path(path).normalize().to_string()
}

///|
fn dirname(path : String) -> String {
  @path.Path(path).normalize().dirname().to_string()
}

///|
fn dir_basename(path : String) -> String {
  @path.Path(path).normalize().basename().to_owned()
}

///|
fn is_identifier_char(ch : Char) -> Bool {
  ch.is_ascii_alphabetic() || ch.is_ascii_digit()
}

///|
fn sanitize_identifier(raw : String) -> String {
  let buf = StringBuilder()
  let mut wrote = false
  let mut last_was_separator = false
  for ch in raw.to_array() {
    if is_identifier_char(ch) {
      if !wrote && ch.is_ascii_digit() {
        buf.write_string("v_")
      }
      buf.write_char(ch.to_ascii_lowercase())
      wrote = true
      last_was_separator = false
    } else if wrote && !last_was_separator {
      buf.write_char('_')
      last_was_separator = true
    }
  }
  if !wrote {
    "bundle"
  } else {
    buf.to_string()
  }
}

///|
fn is_reserved_identifier(name : String) -> Bool {
  match name {
    "as"
    | "async"
    | "catch"
    | "const"
    | "derive"
    | "else"
    | "enum"
    | "false"
    | "fn"
    | "for"
    | "guard"
    | "if"
    | "impl"
    | "import"
    | "in"
    | "is"
    | "let"
    | "loop"
    | "match"
    | "mut"
    | "priv"
    | "pub"
    | "raise"
    | "return"
    | "struct"
    | "test"
    | "trait"
    | "true"
    | "try"
    | "type"
    | "while" => true
    _ => false
  }
}

///|
fn safe_identifier(raw : String) -> String {
  let name = sanitize_identifier(raw)
  if is_reserved_identifier(name) {
    "\{name}_"
  } else {
    name
  }
}

///|
fn fixture_type_name(input : String) -> String {
  let name = safe_identifier(dir_basename(input))
  fixture_type_name_for_name(name)
}

///|
fn fixture_type_name_for_name(name : String) -> String {
  let buf = StringBuilder()
  let mut next_upper = true
  for ch in name.to_array() {
    if ch == '_' {
      next_upper = true
    } else if next_upper {
      buf.write_char(ch.to_ascii_uppercase())
      next_upper = false
    } else {
      buf.write_char(ch)
    }
  }
  buf.write_string("Fixture")
  buf.to_string()
}

///|
priv enum EmbeddedContent {
  Text(String)
  Binary(Bytes)
}

///|
priv struct BundleDir {
  path : String
  entries : Array[BundleEntry]
}

///|
priv enum BundleEntry {
  EntryFile(String, EmbeddedContent)
  EntryDir(String, BundleDir)
}

///|
priv enum InputBundle {
  SingleFile(EmbeddedContent)
  Directory(BundleDir)
}

///|
priv struct FixtureDir {
  type_name : String
  fields : Array[FixtureField]
}

///|
priv enum FixtureField {
  FixtureFile(String, String, EmbeddedContent)
  FixtureDirField(String, FixtureDir)
}

///|
fn contains_name(names : Array[String], name : String) -> Bool {
  for existing in names {
    if existing == name {
      return true
    }
  }
  false
}

///|
fn unique_name(base : String, names : Array[String]) -> String {
  let mut candidate = base
  let mut suffix = 2
  while contains_name(names, candidate) {
    candidate = "\{base}_\{suffix}"
    suffix += 1
  }
  names.push(candidate)
  candidate
}

///|
fn unique_type_name(base : String, names : Array[String]) -> String {
  let mut candidate = base
  let mut suffix = 2
  while contains_name(names, candidate) {
    candidate = "\{base}\{suffix}"
    suffix += 1
  }
  names.push(candidate)
  candidate
}

///|
fn normalize_prune_paths(paths : Array[String]) -> Array[String] {
  let normalized : Array[String] = []
  for path in paths {
    let value = normalize_path(path)
    if value != "" && value != "." {
      normalized.push(value)
    }
  }
  normalized
}

///|
fn should_prune_path(
  relative_path : String,
  prune_paths : Array[String],
) -> Bool {
  let path = normalize_path(relative_path)
  for prune_path in prune_paths {
    if path == prune_path {
      return true
    }
  }
  false
}

///|
fn path_exists(path : String) -> Bool raise {
  @miniio.exists(path) catch {
    Noent | Notdir => false
    err => raise err
  }
}

///|
fn ensure_dir(path : String) -> Unit raise {
  let normalized = normalize_path(path)
  if normalized == "" || normalized == "." {
    return
  }
  if path_exists(normalized) {
    guard @miniio.is_dir(normalized) else { raise @miniio.Errno::Notdir }
    return
  }
  let parent = dirname(normalized)
  if parent != normalized {
    ensure_dir(parent)
  }
  @miniio.mkdir(normalized) catch {
    Exist => {
      guard @miniio.is_dir(normalized) else { raise @miniio.Errno::Notdir }
    }
    err => raise err
  }
}

///|
fn ensure_output_parent_dir(output_path : String) -> Unit raise {
  ensure_dir(dirname(output_path))
}

///|
fn bytes_has_nul(bytes : Bytes) -> Bool {
  for byte in bytes.to_array() {
    if byte == b'\x00' {
      return true
    }
  }
  false
}

///|
fn read_embedded_content(path : String) -> EmbeddedContent raise {
  let data = @miniio.read_file(path)
  let bytes = data.binary()
  if bytes_has_nul(bytes) {
    Binary(bytes)
  } else {
    Text(data.text()) catch {
      _ => Binary(bytes)
    }
  }
}

///|
fn collect_dir(
  dir : String,
  output_path : String,
  prune_paths : Array[String],
  relative_dir : String,
) -> BundleDir raise {
  let normalized_output = normalize_path(output_path)
  let entries : Array[BundleEntry] = []
  for name in @miniio.readdir(dir, include_hidden=true, sort=true) {
    let child = path_join(dir, name)
    let relative_child = if relative_dir == "" {
      name
    } else {
      path_join(relative_dir, name)
    }
    if normalize_path(child) != normalized_output &&
      !should_prune_path(relative_child, prune_paths) {
      if @miniio.is_dir(child) {
        entries.push(
          EntryDir(
            name,
            collect_dir(child, output_path, prune_paths, relative_child),
          ),
        )
      } else {
        let content = read_embedded_content(child)
        entries.push(EntryFile(name, content))
      }
    }
  }
  { path: dir, entries }
}

///|
fn read_input_bundle(
  input : String,
  output_path : String,
  prune_paths : Array[String],
) -> InputBundle raise {
  if @miniio.is_dir(input) {
    Directory(collect_dir(normalize_path(input), output_path, prune_paths, ""))
  } else {
    SingleFile(read_embedded_content(input))
  }
}

///|
fn write_literal_lines(
  buf : StringBuilder,
  content : String,
  indent : String,
) -> Unit {
  for line in content.split("\n") {
    buf <+ "\{indent}#|"
    buf.write_view(line)
    buf <+ "\n"
  }
}

///|
fn content_type_name(content : EmbeddedContent) -> String {
  match content {
    Text(_) => "String"
    Binary(_) => "Bytes"
  }
}

///|
fn write_binary_literal(
  buf : StringBuilder,
  bytes : Bytes,
  indent : String,
) -> Unit {
  let values = bytes.to_array()
  if values.is_empty() {
    buf <+ "\{indent}([] : Bytes)\n"
    return
  }
  buf <+ "\{indent}([\n"
  for i in 0.. Unit {
  match content {
    Text(text) => write_literal_lines(buf, text, indent)
    Binary(bytes) => write_binary_literal(buf, bytes, indent)
  }
}

///|
fn render_file_bundle(input : String, content : EmbeddedContent) -> String {
  let name = safe_identifier(dir_basename(input))
  let const_name = "_embed_\{name}"
  let buf = StringBuilder()
  buf <+
    $|// Generated by moonbit-community/embed from \{input}.
    $|
    $|
  write_content_constant(buf, const_name, content)
  buf <+
    $|///|
    $|pub let \{name} : \{content_type_name(content)} = \{const_name}
    $|
  buf.to_string()
}

///|
fn prepare_fixture_dir(
  dir : BundleDir,
  type_names : Array[String],
  const_names : Array[String],
) -> FixtureDir {
  let field_names : Array[String] = []
  let fields : Array[FixtureField] = []
  for entry in dir.entries {
    match entry {
      EntryFile(name, content) => {
        let field_name = unique_name(safe_identifier(name), field_names)
        let const_name = unique_name(
          "_embed_\{safe_identifier(name)}",
          const_names,
        )
        fields.push(FixtureFile(field_name, const_name, content))
      }
      EntryDir(name, child) => {
        let field_name = unique_name(safe_identifier(name), field_names)
        fields.push(
          FixtureDirField(
            field_name,
            prepare_fixture_dir(child, type_names, const_names),
          ),
        )
      }
    }
  }
  {
    type_name: unique_type_name(fixture_type_name(dir.path), type_names),
    fields,
  }
}

///|
fn write_content_constant(
  buf : StringBuilder,
  name : String,
  content : EmbeddedContent,
) -> Unit {
  buf <+
    $|///|
    $|let \{name} : \{content_type_name(content)} =
    $|
  write_content_literal(buf, content, "  ")
  buf <+ "\n"
}

///|
fn write_fixture_constants(buf : StringBuilder, fixture : FixtureDir) -> Unit {
  for field in fixture.fields {
    match field {
      FixtureFile(_, const_name, content) =>
        write_content_constant(buf, const_name, content)
      FixtureDirField(_, child) => write_fixture_constants(buf, child)
    }
  }
}

///|
fn write_fixture_structs(buf : StringBuilder, fixture : FixtureDir) -> Unit {
  buf <+
    $|///|
    $|pub struct \{fixture.type_name} {
    $|
  for field in fixture.fields {
    match field {
      FixtureFile(name, _, content) =>
        buf <+ "  \{name} : \{content_type_name(content)}\n"
      FixtureDirField(name, child) => buf <+ "  \{name} : \{child.type_name}\n"
    }
  }
  buf <+
    $|}
    $|
    $|
  for field in fixture.fields {
    match field {
      FixtureDirField(_, child) => write_fixture_structs(buf, child)
      _ => ()
    }
  }
}

///|
fn write_fixture_value(
  buf : StringBuilder,
  fixture : FixtureDir,
  indent : String,
) -> Unit {
  let field_indent = "\{indent}  "
  buf <+ "{\n"
  for field in fixture.fields {
    match field {
      FixtureFile(name, const_name, _) =>
        buf <+ "\{field_indent}\{name}: \{const_name},\n"
      FixtureDirField(name, child) => {
        buf <+ "\{field_indent}\{name}: "
        write_fixture_value(buf, child, field_indent)
        buf <+ ",\n"
      }
    }
  }
  buf <+ "\{indent}}"
}

///|
fn render_dir_bundle(dir : BundleDir) -> String {
  let value_name = safe_identifier(dir_basename(dir.path))
  let fixture = prepare_fixture_dir(dir, [], [])
  let buf = StringBuilder()
  buf <+
    $|// Generated by moonbit-community/embed from \{dir.path}.
    $|
    $|
  write_fixture_constants(buf, fixture)
  write_fixture_structs(buf, fixture)
  buf <+
    $|///|
    $|pub let \{value_name} : \{fixture.type_name} =
  buf <+ " "
  write_fixture_value(buf, fixture, "")
  buf <+ "\n"
  buf.to_string()
}

///|
fn render_bundle(input : String, bundle : InputBundle) -> String {
  match bundle {
    SingleFile(content) => render_file_bundle(input, content)
    Directory(dir) => render_dir_bundle(dir)
  }
}

///|
fn count_label(count : Int) -> String {
  if count == 1 {
    "1 file"
  } else {
    "\{count} files"
  }
}

///|
fn count_dir_files(dir : BundleDir) -> Int {
  let mut count = 0
  for entry in dir.entries {
    match entry {
      EntryFile(_, _) => count += 1
      EntryDir(_, child) => count += count_dir_files(child)
    }
  }
  count
}

///|
fn count_bundle_files(bundle : InputBundle) -> Int {
  match bundle {
    SingleFile(_) => 1
    Directory(dir) => count_dir_files(dir)
  }
}

///|
fn run(
  input : String,
  output_path : String,
  prune_paths : Array[String],
) -> Unit {
  let normalized_prune_paths = normalize_prune_paths(prune_paths)
  let bundle = read_input_bundle(input, output_path, normalized_prune_paths) catch {
    err => die("failed to read input \{input}: \{err}")
  }
  let content = render_bundle(input, bundle)
  ensure_output_parent_dir(output_path) catch {
    err => die("failed to create output directory for \{output_path}: \{err}")
  }
  @miniio.write_text_file(
    output_path,
    content,
    create_mode=@miniio.CreateMode::CreateOrTruncate,
  ) catch {
    err => die("failed to write output \{output_path}: \{err}")
  }
  @miniio.stdout.write_text(
    "wrote \{output_path} with \{count_label(count_bundle_files(bundle))}\n",
  ) catch {
    err => die("failed to write stdout: \{err}")
  }
}

///|
fn main {
  try {
    let cli = @argparse.Command(
      "embed",
      about="Embed a file or all files under a directory into a MoonBit source file.",
      options=[
        @argparse.OptionArg(
          "output",
          short='o',
          about="Output MoonBit source path.",
          required=true,
        ),
        @argparse.OptionArg(
          "prune",
          short='p',
          about="Relative file or directory path to skip. May be repeated.",
          action=Append,
        ),
      ],
      positionals=[
        @argparse.PositionArg(
          "path",
          about="File or directory to embed.",
          num_args=@argparse.ValueRange::single(),
        ),
      ],
    )
    let matches = cli.parse()
    let input = matches.values["path"][0]
    let output_path = matches.values["output"][0]
    let prune_paths = match matches.values.get("prune") {
      Some(paths) => paths
      None => []
    }
    run(input, output_path, prune_paths)
  } catch {
    err => {
      println(err.to_string())
      @miniio.exit(2)
    }
  }
}