///|
let max_folder_files : Int32 = 2000

///|
let max_folder_bytes : Int32 = 16 * 1024 * 1024

///|
fn folder_failure(code : String, text : String) -> Json {
  @wire.obj([
    ("success", Json::boolean(false)),
    ("message", @wire.text(code + ": " + text)),
    ("code", @wire.text(code)),
    ("stage", @wire.text("import")),
  ])
}

///|
/// The common leading folder of every path, as a browser folder picker gives (`rentals/morphir.json`), or "".
fn common_folder(paths : Array[String]) -> String {
  guard paths is [first, ..] else { return "" }
  guard first.find("/") is Some(slash) else { return "" }
  let prefix = first.unsafe_substring(start=0, end=slash + 1)
  if paths.iter().all(p => p.has_prefix(prefix)) {
    prefix
  } else {
    ""
  }
}

///|
/// A source directory without a leading `./` and trailing `/`: `./elm-src/` is `elm-src`.
fn normalized_directory(text : String) -> String {
  let mut directory = text
  while directory.strip_prefix("./") is Some(rest) {
    directory = rest.to_owned()
  }
  while directory.strip_suffix("/") is Some(rest) {
    directory = rest.to_owned()
  }
  directory
}

///|
/// Maps a folder with a morphir.json to an Elm project request, with the engine's discovery rules
/// (`@workspace.discover`): name, sourceDirectory and exposedModules, and its warnings.
fn import_folder(request : Json) -> Json raise {
  let raw = @wire.array(@wire.field(request, "files"))
  fn too_large() -> Json {
    folder_failure(
      "workbench.folder_too_large", "Select a project folder with at most 2000 files and 16 MiB of files and paths",
    )
  }
  if raw.length() > max_folder_files {
    return too_large()
  }
  let entries : Array[(String, @data.Content)] = []
  let mut remaining = max_folder_bytes
  for file in raw {
    let path = @wire.string(@wire.field(file, "path"))
    remaining -= @utf8.encode(path).length()
    if remaining < 0 {
      return too_large()
    }
    let content = match @wire.field(file, "content") {
      String(text) => {
        if text.length() > remaining {
          return too_large()
        }
        remaining -= @utf8.encode(text).length()
        @data.Text(text)
      }
      Object(record) if record.length() == 2 &&
        record.get("encoding") == Some(Json::string("base64")) &&
        record.get("content") is Some(String(encoded)) => {
        if encoded.length() > (remaining + 2) / 3 * 4 {
          return too_large()
        }
        let bytes = @base64.decode(encoded) catch {
          _ =>
            return folder_failure(
              "workbench.folder_configuration",
              "Invalid binary folder entry: " + path,
            )
        }
        if @base64.encode(bytes[:]) != encoded {
          return folder_failure(
            "workbench.folder_configuration",
            "Invalid binary folder entry: " + path,
          )
        }
        remaining -= bytes.length()
        @data.Binary(bytes)
      }
      _ =>
        return folder_failure(
          "workbench.folder_configuration",
          "Invalid folder entry content: " + path,
        )
    }
    if remaining < 0 {
      return too_large()
    }
    entries.push((path, content))
  }
  let prefix = common_folder(entries.map(e => e.0))
  let files : Map[String, String] = Map([])
  let binary_files : Map[String, Bytes] = Map([])
  let invalid_paths : Array[String] = []
  for entry in entries {
    let path = entry.0.unsafe_substring(
      start=prefix.length(),
      end=entry.0.length(),
    )
    if (try
        @workspace.normalize(entry.0) == entry.0 &&
        @workspace.normalize(path) == path &&
        path != "" &&
        ({
          ignore(@ids.SourcePath::parse(path))
          true
        })
      catch {
        _ => false
      }) ==
      false {
      invalid_paths.push("Skipped " + path + ": not a valid source path")
      continue
    }
    if files.contains(path) || binary_files.contains(path) {
      return folder_failure(
        "workbench.folder_configuration",
        "Duplicate folder entry: " + path,
      )
    }
    match entry.1 {
      Text(text) => files[path] = text
      Binary(bytes) => binary_files[path] = bytes
    }
  }
  let workspace_files = @workspace_files.Files::new(files, binary_files~) catch {
    @config.Invalid(message) =>
      return folder_failure("workbench.folder_configuration", message)
    error =>
      return folder_failure("workbench.folder_configuration", error.to_string())
  }
  // Flat morphir.json keeps the existing Elm-project import response. Modern
  // configuration documents and layered folders use workspace import instead.
  let legacy_elm = match files.get("morphir.json") {
    Some(text) => {
      let document = @config.from_json(@wire.parse(text), origin="morphir.json") catch {
        @wire.Invalid(_, message) =>
          return folder_failure("workbench.folder_configuration", message)
        error =>
          return folder_failure(
            "workbench.folder_configuration",
            error.to_string(),
          )
      }
      document.get("project") is None && document.get("workspace") is None
    }
    None => false
  }
  if @workspace.primary_paths("").any(path => {
      files.contains(path) && (path != "morphir.json" || !legacy_elm)
    }) ||
    files.contains("morphir-workspace.json") {
    let workspace = workspace_files.discover() catch {
      @config.Invalid(message) =>
        return folder_failure("workbench.folder_configuration", message)
      error =>
        return folder_failure(
          "workbench.folder_configuration",
          error.to_string(),
        )
    }
    return @wire.obj([
      ("success", Json::boolean(true)),
      ("workspace", workspace_files.to_json()),
      ("discovery", workspace.to_json()),
      (
        "warnings",
        Json::array((workspace.warnings + invalid_paths).map(@wire.text)),
      ),
      ("skipped", Json::number(invalid_paths.length().to_double())),
    ])
  }
  guard files.get("morphir.json") is Some(manifest_text) else {
    return folder_failure(
      "workbench.folder_without_morphir_json", "The folder has no morphir.json. Open a morphir-elm project folder, or add a morphir.json with name, sourceDirectory and exposedModules",
    )
  }
  // Discovery needs only the manifest: it maps name, sourceDirectory and exposedModules and gives the warnings.
  let workspace = @workspace.discover({
    files: Map([("morphir.json", manifest_text)]),
    directories: [""],
  }) catch {
    error =>
      return folder_failure("workbench.folder_configuration", error.to_string())
  }
  guard workspace.projects is [project, ..] else {
    return folder_failure(
      "workbench.folder_configuration", "No project in morphir.json",
    )
  }
  if project.error is Some(message) {
    return folder_failure("workbench.folder_configuration", message)
  }
  let name = project.document.text("project.name", project.name)
  // The reply must load as a project: the same package-name rule as the editor.
  ignore(
    package_name(name) catch {
      @wire.Invalid(_, message) =>
        return folder_failure("workbench.folder_configuration", message)
      error =>
        return folder_failure(
          "workbench.folder_configuration",
          error.to_string(),
        )
    },
  )
  let source = normalized_directory(
    project.document.text("project.source_directory", "src"),
  )
  let source_prefix = if source == "" || source == "." {
    ""
  } else {
    source + "/"
  }
  let warnings = workspace.warnings + invalid_paths
  let kept : Array[Json] = []
  let mut skipped = invalid_paths.length()
  for path, content in files {
    if path == "morphir.json" {
      continue
    }
    match path.strip_prefix(source_prefix) {
      Some(rest) if path.has_suffix(".elm") => {
        let target = "src/" + rest.to_owned()
        let valid = try {
          ignore(@ids.SourcePath::parse(target))
          true
        } catch {
          _ => false
        }
        if valid {
          kept.push(
            @wire.obj([
              ("path", @wire.text(target)),
              ("content", @wire.text(content)),
            ]),
          )
        } else {
          warnings.push("Skipped " + path + ": not a valid source path")
          skipped += 1
        }
      }
      _ => skipped += 1
    }
  }
  if kept.is_empty() {
    return folder_failure(
      "workbench.folder_without_sources",
      "No .elm file under the source directory " + source,
    )
  }
  @wire.obj([
    ("success", Json::boolean(true)),
    ("workspace", workspace_files.to_json()),
    (
      "project",
      @wire.obj([
        ("name", @wire.text(name)),
        ("languageId", @wire.text("elm")),
        (
          "exposedModules",
          Json::array(
            project.document.strings("project.exposed_modules").map(@wire.text),
          ),
        ),
        ("files", Json::array(kept)),
      ]),
    ),
    ("warnings", Json::array(warnings.map(@wire.text))),
    ("skipped", Json::number(skipped.to_double())),
  ])
}