///|
pub struct BuildRunner {
  config : BuildConfig
  plugins : Map[
    String,
    (@smithy.Model, Json) -> Map[String, String] raise BuildError,
  ]
  sources : Map[String, String]
  source_conflicts : Array[String]
  source_origins : Set[String]
  source_metadata : Set[String]
  projection_imports : Map[String, Array[@smithy.ModelInput]]
}

///|
pub fn BuildRunner::new(config : BuildConfig) -> BuildRunner {
  {
    config,
    plugins: Map([]),
    sources: Map([]),
    source_conflicts: [],
    source_origins: Set([]),
    source_metadata: Set([]),
    projection_imports: Map([]),
  }
}

///|
pub fn BuildRunner::add_source(
  self : BuildRunner,
  name : String,
  content : String,
  source_file~ : String? = None,
) -> BuildRunner {
  if self.sources.contains(name) {
    self.source_conflicts.push(
      "Conflicting source filename `" +
      name +
      "` from `" +
      source_file.unwrap_or(name) +
      "`",
    )
    return self
  }
  self.sources[name] = content
  self.source_origins.add(source_file.unwrap_or(name))
  if name.has_suffix(".smithy") {
    if @syntax.parse(@syntax.SourceText::new(content)).ast is Some(file) {
      for metadata in file.metadata {
        self.source_metadata.add(metadata.key.value)
      }
    }
  } else if name.has_suffix(".json") {
    let document = @json.parse(content) catch { _ => Json::null() }
    if document is { "metadata": Object(metadata), .. } {
      for key, _ in metadata {
        self.source_metadata.add(key)
      }
    }
  }
  self
}

///|
fn BuildRunner::projected_sources(
  self : BuildRunner,
  original : @smithy.Model,
  projected : @smithy.Model,
) -> @smithy.Model {
  let shapes : Map[@smithy.ShapeId, @smithy.Shape] = Map([])
  for id, shape in projected.shapes {
    let source = match original.shapes.get(id.root()) {
      None => true
      Some(original_shape) =>
        self.source_origins.contains(original_shape.source.file)
    }
    if source {
      shapes[id] = shape
    }
  }
  let metadata : Map[String, @smithy.NodeValue] = Map([])
  for key, value in projected.metadata {
    if !original.metadata.contains(key) || self.source_metadata.contains(key) {
      metadata[key] = value
    }
  }
  { shapes, metadata, }
}

///|
/// Supply local projection imports; the caller owns file access.
pub fn BuildRunner::add_projection_import(
  self : BuildRunner,
  projection : String,
  input : @smithy.ModelInput,
) -> BuildRunner {
  let inputs = self.projection_imports.get(projection).unwrap_or([])
  inputs.push(input)
  self.projection_imports[projection] = inputs
  self
}

///|
fn BuildRunner::merge_projection_imports(
  self : BuildRunner,
  model : @smithy.Model,
  projection : String,
) -> @smithy.Model raise BuildError {
  let inputs = self.projection_imports.get(projection).unwrap_or([])
  if inputs.is_empty() {
    return model
  }
  let document = model.to_json_ast(preserve_mixins=true) catch {
    _ =>
      raise InvalidBuildConfig(
        "Could not serialize model before projection imports",
      )
  }
  let loader = @smithy.Loader::new().add_ast("", document)
  for input in inputs {
    ignore(loader.add(input))
  }
  let result = loader.assemble()
  guard result.model is Some(merged) else {
    raise InvalidBuildConfig(
      "Could not merge imports of projection `" +
      projection +
      "`: " +
      result.diagnostics.map(d => d.message).join("\n"),
    )
  }
  let shapes : Map[@smithy.ShapeId, @smithy.Shape] = Map([])
  for id, shape in merged.shapes {
    shapes[id] = match model.shapes.get(id) {
      Some(original) => { ..shape, source: original.source, }
      None => shape
    }
  }
  { shapes, metadata: merged.metadata, }
}

///|
/// Register a plugin that writes relative filenames for a projected model.
pub fn BuildRunner::register_plugin(
  self : BuildRunner,
  name : String,
  execute : (@smithy.Model, Json) -> Map[String, String] raise BuildError,
) -> BuildRunner {
  self.plugins[name] = execute
  self
}

///|
fn transform_trait_id(text : String) -> String {
  if text.contains("#") {
    text
  } else {
    "smithy.api#" + text
  }
}

///|
/// Apply one built-in transform without validating or running plugins.
pub fn apply_transform(
  model : @smithy.Model,
  transform : TransformConfig,
) -> @smithy.Model raise BuildError {
  transform_model(model, transform)
}

///|
fn transform_model(
  model : @smithy.Model,
  transform : TransformConfig,
  original_model~ : @smithy.Model? = None,
  original_events~ : Array[@smithy.ValidationEvent]? = None,
) -> @smithy.Model raise BuildError {
  if transform_misc(model, transform, original_model~, original_events~)
    is Some(projected) {
    return projected
  }
  if transform_rewrite(model, transform) is Some(projected) {
    return projected
  }
  if transform_shape_filter(model, transform) is Some(projected) {
    return projected
  }
  let shapes : Map[@smithy.ShapeId, @smithy.Shape] = Map([])
  let metadata : Map[String, @smithy.NodeValue] = Map([])
  for key, value in model.metadata {
    metadata[key] = value
  }
  let trait_names = config_strings(transform.args, "traits")
  let names = trait_names.map(transform_trait_id)
  for id, shape in model.shapes {
    let traits : Map[@smithy.ShapeId, @smithy.NodeValue] = Map([])
    for trait_id, value in shape.traits {
      let included = names.contains(trait_id.to_string())
      if (transform.name == "excludeTraits" && !included) ||
        (transform.name == "includeTraits" && included) ||
        (transform.name != "excludeTraits" && transform.name != "includeTraits") {
        traits[trait_id] = value
      }
    }
    shapes[id] = { ..shape, traits, }
  }
  match transform.name {
    "excludeTraits" | "includeTraits" => ()
    "includeShapesByTag" | "excludeShapesByTag" => {
      let tags = config_strings(transform.args, "tags")
      let filtered : Map[@smithy.ShapeId, @smithy.Shape] = Map([])
      for id, shape in shapes {
        let tagged = match shape.trait_value("smithy.api#tags") {
          Some(value) =>
            match @json.to_json(value) {
              Array(values) =>
                values
                .iter()
                .any(v => {
                  match v {
                    String(tag) => tags.contains(tag)
                    _ => false
                  }
                })
              _ => false
            }
          None => false
        }
        if id.to_string().has_prefix("smithy.api#") ||
          (transform.name == "includeShapesByTag" && tagged) ||
          (transform.name == "excludeShapesByTag" && !tagged) {
          filtered[id] = shape
        }
      }
      return { shapes: filtered, metadata, }
    }
    "excludeMetadata" | "includeMetadata" => {
      let keys = config_strings(transform.args, "keys")
      let result : Map[String, @smithy.NodeValue] = Map([])
      for key, value in metadata {
        let included = keys.contains(key)
        if (transform.name == "excludeMetadata" && !included) ||
          (transform.name == "includeMetadata" && included) {
          result[key] = value
        }
      }
      return { shapes, metadata: result, }
    }
    _ =>
      raise InvalidBuildConfig(
        "Unknown projection transform `" + transform.name + "`",
      )
  }
  { shapes, metadata, }
}

///|
fn model_json(
  model : @smithy.Model,
  settings : Json,
) -> String raise BuildError {
  let include_prelude = config_bool(settings, "includePreludeShapes")
  let shapes = model.shapes.filter((_, shape) => {
    shape.source.file != ""
  })
  let model = { ..model, shapes, }
  let document = model.to_json_ast(include_prelude~, preserve_mixins=true) catch {
    _ => raise InvalidBuildConfig("Could not serialize projected model")
  }
  @syntax.encode_json_ast(document).stringify(indent=4) + "\n"
}

///|
fn build_info(model : @smithy.Model, projection : ProjectionConfig) -> String {
  let metadata : Map[String, Json] = Map([])
  for key, value in model.metadata {
    metadata[key] = @json.to_json(value)
  }
  let trait_names : Array[String] = []
  let trait_defs : Array[String] = []
  let services : Array[String] = []
  let operations : Array[String] = []
  let resources : Array[String] = []
  for id, shape in model.shapes {
    if shape.source.file == "" {
      continue
    }
    if shape.trait_value("smithy.api#trait") is Some(_) {
      trait_defs.push(id.to_string())
    }
    for trait_id, _ in shape.traits {
      if !trait_names.contains(trait_id.to_string()) {
        trait_names.push(trait_id.to_string())
      }
    }
    match shape.body {
      @smithy.Service(..) => services.push(id.to_string())
      @smithy.Operation(..) => operations.push(id.to_string())
      @smithy.Resource(..) => resources.push(id.to_string())
      _ => ()
    }
  }
  for ids in [trait_names, trait_defs, services, operations, resources] {
    ids.sort_by((a, b) => a.lexical_compare(b))
  }
  let trait_names = trait_names.filter(name => trait_defs.contains(name))
  let validation_events : Array[Json] = []
  for event in model.validation_events() {
    let encoded : Map[String, Json] = {
      "id": Json::string(event.id),
      "severity": Json::string(event.severity),
      "message": Json::string(event.message),
      "filename": Json::string(event.source.map(s => s.file).unwrap_or("-")),
      "line": Json::number(0.0),
      "column": Json::number(0.0),
    }
    if event.shape is Some(id) {
      encoded["shapeId"] = Json::string(id.to_string())
    }
    if event.suppression_reason is Some(reason) {
      encoded["suppressionReason"] = Json::string(reason)
    }
    validation_events.push(Json::object(encoded))
  }
  let transforms = projection.transforms.map(t => {
    Json::object({ "name": Json::string(t.name), "args": t.args })
  })
  let projection_fields : Map[String, Json] = {
    "abstract": Json::boolean(projection.is_abstract),
    "imports": Json::array(projection.imports.map(Json::string)),
    "transforms": Json::array(transforms),
    "plugins": Json::object(projection.plugins),
  }
  let fields : Map[String, Json] = {
    "version": Json::string("1.0"),
    "projectionName": Json::string(projection.name),
    "projection": Json::object(projection_fields),
    "validationEvents": Json::array(validation_events),
    "traitNames": Json::array(trait_names.map(Json::string)),
    "traitDefNames": Json::array(trait_defs.map(Json::string)),
    "serviceShapeIds": Json::array(services.map(Json::string)),
    "operationShapeIds": Json::array(operations.map(Json::string)),
    "resourceShapeIds": Json::array(resources.map(Json::string)),
    "metadata": Json::object(metadata),
  }
  Json::object(fields).stringify(indent=4) + "\n"
}

///|
fn BuildRunner::apply_transforms(
  self : BuildRunner,
  model : @smithy.Model,
  projection : ProjectionConfig,
  active : Array[String],
  original_model~ : @smithy.Model? = None,
  original_events~ : Array[@smithy.ValidationEvent]? = None,
) -> @smithy.Model raise BuildError {
  if active.contains(projection.name) {
    raise InvalidBuildConfig(
      "Projection apply cycle at `" + projection.name + "`",
    )
  }
  let path = active.copy()
  path.push(projection.name)
  let mut projected = model
  let original_model = original_model.unwrap_or(model)
  let original_events = original_events.unwrap_or_else(() => {
    model.validation_events()
  })
  for transform in projection.transforms {
    if transform.name == "apply" {
      for
        name in config_strings(
          misc_args(transform, "projections"),
          "projections",
        ) {
        let mut found : ProjectionConfig? = None
        for candidate in self.config.projections {
          if candidate.name == name {
            found = Some(candidate)
            break
          }
        }
        guard found is Some(target) else {
          raise InvalidBuildConfig("Unknown applied projection `" + name + "`")
        }
        projected = self.apply_transforms(
          projected,
          target,
          path,
          original_model=Some(original_model),
          original_events=Some(original_events),
        )
      }
    } else {
      projected = transform_model(
        projected,
        transform,
        original_model=Some(original_model),
        original_events=Some(original_events),
      )
    }
  }
  projected
}

///|
fn BuildRunner::validate_config(self : BuildRunner) -> Unit raise BuildError {
  if !self.source_conflicts.is_empty() {
    raise InvalidBuildConfig(self.source_conflicts.join("\n"))
  }
  let transform_names = [
    "apply", "changeStringEnumsToEnumShapes", "changeTypes", "excludeMetadata", "excludeShapesBySelector",
    "excludeShapesByTag", "excludeShapesByTrait", "excludeTags", "excludeTraits",
    "excludeTraitsByTag", "filterSuppressions", "flattenAndRemoveMixins", "flattenClosureNamespaces",
    "flattenNamespaces", "includeClosures", "includeMetadata", "includeNamespaces",
    "includeServices", "includeShapesBySelector", "includeShapesByTag", "includeTags",
    "includeTraits", "includeTraitsByTag", "makeIdempotencyTokensClientOptional",
    "removeDeprecatedShapes", "removeTraitDefinitions", "removeUnusedShapes", "renameShapes",
  ]
  for projection in self.config.projections {
    if !plugin_name_part(projection.name) {
      raise InvalidBuildConfig(
        "Invalid projection name `" + projection.name + "`",
      )
    }
    for transform in projection.transforms {
      if !transform_names.contains(transform.name) {
        raise InvalidBuildConfig(
          "Unknown projection transform `" + transform.name + "`",
        )
      }
    }
  }
  let plugin_sets = [self.config.plugins]
  for projection in self.config.projections {
    if !projection.is_abstract {
      plugin_sets.push(projection.plugins)
    }
  }
  for plugins in plugin_sets {
    for name, _ in plugins {
      let (plugin, _) = plugin_id(name)
      if !self.config.ignore_missing_plugins &&
        !self.plugins.contains(plugin) &&
        !["model", "sources", "build-info"].contains(plugin) {
        raise InvalidBuildConfig("Unknown build plugin `" + name + "`")
      }
    }
  }
}

///|
/// Artifacts from successful plugins and failures reported by the build.
pub(all) struct BuildResult {
  artifacts : Map[String, String]
  errors : Array[String]
} derive(Debug)

///|
/// Produce artifacts, raising if any projection failed.
pub fn BuildRunner::run(
  self : BuildRunner,
  model : @smithy.Model,
) -> Map[String, String] raise BuildError {
  let result = self.run_result(model)
  if !result.errors.is_empty() {
    raise InvalidBuildConfig(result.errors.join("\n"))
  }
  result.artifacts
}

///|
/// Preserve successful projections and build information when another projection fails.
pub fn BuildRunner::run_result(
  self : BuildRunner,
  model : @smithy.Model,
) -> BuildResult {
  let artifacts : Map[String, String] = Map([])
  let errors : Array[String] = []
  self.validate_config() catch {
    InvalidBuildConfig(message) => return { artifacts, errors: [message], }
  }
  for event in model.validation_events() {
    if event.severity == "DANGER" || event.severity == "ERROR" {
      errors.push("Model validation failed: " + event.id + ": " + event.message)
    }
  }
  if !errors.is_empty() {
    return { artifacts, errors, }
  }
  let projections = self.config.projections.copy()
  if !projections.iter().any(p => p.name == "source") {
    projections.push({
      name: "source",
      is_abstract: false,
      imports: [],
      transforms: [],
      plugins: Map([]),
    })
  }
  for projection in projections {
    if projection.is_abstract {
      continue
    }
    try {
      if projection.name == "source" && !projection.transforms.is_empty() {
        raise InvalidBuildConfig(
          "The source projection cannot contain transforms",
        )
      }
      let projection_model = self.merge_projection_imports(
        model,
        projection.name,
      )
      let projected = self.apply_transforms(projection_model, projection, [])
      let findings = projected.validation_events()
      let valid_model = !findings
        .iter()
        .any(event => event.severity == "DANGER" || event.severity == "ERROR")
      for event in findings {
        if event.severity == "DANGER" || event.severity == "ERROR" {
          errors.push(
            "Projection `" +
            projection.name +
            "` validation failed: " +
            event.id +
            ": " +
            event.message,
          )
        }
      }
      self.emit_plugins(
        projected,
        projection_model,
        projection,
        artifacts,
        valid_model~,
      )
    } catch {
      InvalidBuildConfig(message) =>
        errors.push("Projection `" + projection.name + "`: " + message)
    }
  }
  { artifacts, errors, }
}