///|
fn plugin_name_part(value : String) -> Bool {
  let chars = value.to_array()
  fn alphanumeric(c : Char) -> Bool {
    (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
  }
  !chars.is_empty() &&
  alphanumeric(chars[0]) &&
  chars.iter().all(c => alphanumeric(c) || c == '-' || c == '_' || c == '.')
}

///|
fn plugin_id(name : String) -> (String, String) raise BuildError {
  let parts = name.split("::").to_array()
  if parts.length() > 2 ||
    !parts.iter().all(part => plugin_name_part(part.to_owned())) {
    raise InvalidBuildConfig("Invalid build plugin name `" + name + "`")
  }
  (parts[0].to_owned(), parts[parts.length() - 1].to_owned())
}

///|
fn plugin_relative_file(value : String) -> Bool {
  let normalized = value.replace_all(old="\\", new="/")
  let chars = normalized.to_array()
  if chars.is_empty() ||
    normalized.has_prefix("/") ||
    normalized.has_suffix("/") ||
    normalized.contains("\u{0}") ||
    (chars.length() > 1 && chars[1] == ':') {
    return false
  }
  let parts = normalized.split("/").to_array()
  !parts.iter().any(part => part == "..") && parts[parts.length() - 1] != "."
}

///|
fn BuildRunner::emit_plugin_sources(
  self : BuildRunner,
  model : @smithy.Model,
  original : @smithy.Model,
  projection : ProjectionConfig,
  prefix : String,
  artifacts : Map[String, String],
) -> Unit raise BuildError {
  if projection.name == "source" {
    let names = self.sources.keys().to_array()
    names.sort()
    for name in names {
      artifacts[prefix + name] = self.sources[name]
    }
    artifacts[prefix + "manifest"] = names.join("\n") + "\n"
  } else {
    artifacts[prefix + "model.json"] = model_json(
      self.projected_sources(original, model),
      Json::object(Map([])),
    )
    artifacts[prefix + "manifest"] = "model.json\n"
  }
}

///|
fn BuildRunner::emit_plugins(
  self : BuildRunner,
  model : @smithy.Model,
  original : @smithy.Model,
  projection : ProjectionConfig,
  artifacts : Map[String, String],
  valid_model~ : Bool = true,
) -> Unit raise BuildError {
  let plugins : Map[String, Json] = Map([])
  for name, settings in self.config.plugins {
    plugins[name] = settings
  }
  // SmithyBuildConfig reinstalls the default plugin settings before merging projections.
  for builtin in ["model", "sources", "build-info"] {
    plugins[builtin] = Json::object(Map([]))
  }
  for name, settings in projection.plugins {
    plugins[name] = settings
  }
  let names = plugins.keys().to_array()
  names.sort()
  let artifact_names : Map[String, Bool] = Map([])
  let resolved : Array[(String, String, Json)] = []
  // Resolve the complete set before running any plugin, so invalid aliases cannot
  // partially execute a projection or overwrite another plugin's artifacts.
  for name in names {
    let (plugin, artifact_name) = plugin_id(name)
    if artifact_names.contains(artifact_name) {
      raise InvalidBuildConfig(
        "Multiple plugins use the same artifact name '" +
        artifact_name +
        "' in the '" +
        projection.name +
        "' projection",
      )
    }
    artifact_names[artifact_name] = true
    resolved.push((plugin, artifact_name, plugins[name]))
  }
  let scheduled : Array[(String, String, Json)] = []
  for (plugin, artifact_name, settings) in resolved {
    if self.plugins.contains(plugin) ||
      ["model", "sources", "build-info"].contains(plugin) {
      scheduled.push((plugin, artifact_name, settings))
    } else if !self.config.ignore_missing_plugins {
      let name = if plugin == artifact_name {
        plugin
      } else {
        plugin + "::" + artifact_name
      }
      raise InvalidBuildConfig(
        "Unable to find a plugin for `" +
        name +
        "` in the `" +
        projection.name +
        "` projection. Is this the correct spelling? Are you missing a dependency? Is your classpath configured correctly?",
      )
    }
  }
  let execution_errors : Array[String] = []
  for (plugin, artifact_name, settings) in scheduled {
    try {
      let prefix = projection.name + "/" + artifact_name + "/"
      if self.plugins.get(plugin) is Some(execute) {
        if !valid_model {
          continue
        }
        let files = execute(model, settings)
        for file, _ in files {
          if !plugin_relative_file(file) {
            raise InvalidBuildConfig(
              "Plugin `" +
              plugin +
              "` must write a relative artifact file, but returned `" +
              file +
              "`",
            )
          }
        }
        for file, content in files {
          artifacts[prefix + file] = content
        }
      } else {
        if !valid_model && plugin != "build-info" {
          continue
        }
        match plugin {
          "model" =>
            artifacts[prefix + "model.json"] = model_json(model, settings)
          "sources" =>
            self.emit_plugin_sources(
              model, original, projection, prefix, artifacts,
            )
          "build-info" =>
            artifacts[prefix + "smithy-build-info.json"] = build_info(
              model, projection,
            )
          _ => ()
        }
      }
    } catch {
      InvalidBuildConfig(message) => execution_errors.push(message)
    }
  }
  if !execution_errors.is_empty() {
    raise InvalidBuildConfig(execution_errors.join("\n"))
  }
}