///| Configuration precedence: manifest, workspace script, project script, explicit script.

///|
/// Transforms accumulate in that order. Results are returned without host writes.
pub fn Engine::run(
  self : Engine,
  host : Host,
  input : Input,
  config~ : String = "",
  bind_runtime~ : (@scheme.Runtime) -> Unit raise = fn(_) { () },
) -> Report {
  let artifacts : Array[Artifact] = []
  let diagnostics : Array[Diagnostic] = []
  let outputs : Map[String, Bool] = Map([])
  let seen_projects : Map[String, Bool] = Map([])
  fn project(
    root : String,
    project_manifest : Bool,
    file : String?,
    inherited : String,
  ) -> Unit {
    let mut stage = "configuration"
    try {
      let runtime = config_runtime(self.limits)
      bind_runtime(runtime)
      let settings = Config::{
        frontend: "scheme",
        frontend_provider: None,
        frontend_profile: None,
        backend: "ir-json",
        output: "dist",
        package_name: "user",
        source: "",
        inference: "off",
        transforms: [],
      }
      if project_manifest {
        let path = join(root, "morphir.toml")
        if !(host.exists)(path) {
          raise @scheme.Invalid("Missing project manifest: " + path)
        }
        let document = (host.read)(path) |> @configuration.parse(origin=path)
        settings.package_name = document.text("project.name", "user")
        settings.source = normalize(
          document.text("project.source_directory", "src"),
        )
      }
      if inherited != "" {
        settings.apply(runtime, inherited)
      }
      let config_path = join(root, "morphir.config.scm")
      if (host.exists)(config_path) {
        settings.apply(runtime, (host.read)(config_path))
      }
      if config != "" {
        settings.apply(runtime, config)
      }
      if settings.inference != "off" {
        raise @scheme.Invalid(
          "pipeline.inference needs the planned pipeline (Engine::plan)",
        )
      }
      let frontend = match self.select_source_frontend(settings) {
        Some(value) => value
        None => raise @scheme.Invalid("Unknown frontend: " + settings.frontend)
      }
      let backend = match self.backends.get(settings.backend) {
        Some(value) => value
        None => raise @scheme.Invalid("Unknown backend: " + settings.backend)
      }
      if !(frontend.compiler is FileCompiler(_)) {
        raise @scheme.Invalid(
          "frontend.typed_backend_required: Rich source units require a planned checkpoint or generation backend",
        )
      }
      if !backend.extension.has_prefix(".") || backend.extension.contains("/") {
        raise @scheme.Invalid("Backend extension must be a dotted file suffix")
      }
      let source_root = if file is Some(_) {
        root
      } else {
        join(root, settings.source)
      }
      let output_root = join(root, settings.output)
      if source_root == output_root || within(output_root, source_root) {
        raise @scheme.Invalid("Source directory is inside output directory")
      }
      stage = "discovery"
      let paths = match file {
        Some(path) => [path]
        None => (host.list)(source_root)
      }
      paths.sort()
      let seen : Map[String, Bool] = Map([])
      for path in paths {
        let path = normalize(path)
        let relative_path = relative(source_root, path)
        if seen.contains(path) {
          continue
        }
        seen[path] = true
        if file is None &&
          (
            path == config_path ||
            path == join(root, "morphir.toml") ||
            within(output_root, path)
          ) {
          continue
        }
        if file is None &&
          !frontend.extensions.any(extension => path.has_suffix(extension)) {
          continue
        }
        let mut file_stage = "read"
        try {
          let source = Source::{
            path,
            relative_path,
            content: (host.read)(path),
            package_name: settings.package_name,
            module_name: stem(relative_path),
          }
          file_stage = "frontend"
          let mut ir = frontend.compile_unit(source, stem(relative_path), fn() {
              false
            }).distribution
          file_stage = "transform"
          runtime.set_global("source-path", @scheme.Text(path))
          runtime.set_global("project-root", @scheme.Text(root))
          for transform in settings.transforms {
            ir = runtime.call(transform, [@scheme.IRFile(ir)]).as_file()
          }
          file_stage = "backend"
          let content = (backend.emit)(ir)
          file_stage = "output"
          let path = join(output_root, stem(relative_path) + backend.extension)
          if outputs.contains(path) {
            raise @scheme.Invalid("Duplicate output path: " + path)
          }
          outputs[path] = true
          artifacts.push({ path, source: source.path, content, })
        } catch {
          error =>
            diagnostics.push({
              path,
              stage: file_stage,
              message: diagnostic_message(error),
              details: None,
              problem: None,
            })
        }
      }
    } catch {
      error =>
        diagnostics.push({
          path: root,
          stage,
          message: diagnostic_message(error),
          details: None,
          problem: None,
        })
    }
  }
  let mut requested_path = ""
  try {
    match input {
      File(path) => {
        requested_path = path
        let path = normalize(path)
        if path == "" {
          raise @scheme.Invalid("File path must be nonempty")
        }
        project(parent(path), false, Some(path), "")
      }
      Directory(root) => {
        requested_path = root
        project(normalize(root), false, None, "")
      }
      Project(root) => {
        requested_path = root
        project(normalize(root), true, None, "")
      }
      Workspace(root) => {
        requested_path = root
        let root = normalize(root)
        let manifest_path = join(root, "morphir.toml")
        if !(host.exists)(manifest_path) {
          raise @scheme.Invalid("Missing workspace manifest: " + manifest_path)
        }
        let projects = (host.read)(manifest_path)
          |> @configuration.parse(origin=manifest_path)
          |> document => { document.strings("workspace.members") }
        let config_path = join(root, "morphir.config.scm")
        let inherited = if (host.exists)(config_path) {
          (host.read)(config_path)
        } else {
          ""
        }
        for item in projects {
          try {
            if item.contains("*") ||
              item.contains("?") ||
              item.contains("[") ||
              item.contains("{") {
              raise @scheme.Invalid(
                "Engine::run workspace members must be literal paths: " + item,
              )
            }
            let path = join(root, item)
            if path == root {
              raise @scheme.Invalid("Workspace projects must be subdirectories")
            }
            if seen_projects.contains(path) {
              continue
            }
            seen_projects[path] = true
            project(path, true, None, inherited)
          } catch {
            error =>
              diagnostics.push({
                path: root,
                stage: "discovery",
                message: diagnostic_message(error),
                details: None,
                problem: None,
              })
          }
        }
      }
    }
  } catch {
    error =>
      diagnostics.push({
        path: requested_path,
        stage: "discovery",
        message: diagnostic_message(error),
        details: None,
        problem: None,
      })
  }
  { processed: artifacts.length(), artifacts, diagnostics, }
}

///|
fn rejection_message(
  code : String,
  detail : String,
  location : @ir.SourceLocation?,
) -> String {
  code +
  ": " +
  detail +
  (match location {
    Some(loc) =>
      " at " +
      loc.start_line.to_string() +
      ":" +
      loc.start_column.to_string() +
      "-" +
      loc.end_line.to_string() +
      ":" +
      loc.end_column.to_string()
    None => ""
  })
}

///|
fn diagnostic_message(error : Error) -> String {
  match error {
    Rejected(code, detail, location) =>
      rejection_message(code, detail, location)
    RejectedSources(problems) =>
      problems
      .map(p => rejection_message(p.code, p.detail, p.location))
      .join("\n")
    GenerationFailed(problems) =>
      problems
      .map(p => p.code + " at " + p.node_path + ": " + p.detail)
      .join("\n")
    @configuration.Invalid(message) => message
    @wire.Invalid(code, message) => code + ": " + message
    @data.Invalid(code, message) => code + ": " + message
    @moonbit.Invalid(code, context, message) =>
      code + " at " + context + ": " + message
    _ => {
      let code = @wire.diagnostic(error)
      if code == "unknown_error" {
        @scheme.error_message(error)
      } else {
        code
      }
    }
  }
}