///|
pub fn load_project_config_from_file(
  path : String,
) -> ProjectConfig raise ProjectConfigError {
  load_project_config_from_text(
    read_project_config_text(path),
    config_base_dir(path),
    name=path,
  )
}

///|
pub fn load_project_config_from_text(
  source : String,
  base_dir : String,
  name? : String = "proton.project.json",
) -> ProjectConfig raise ProjectConfigError {
  let json = @json.parse(source) catch {
    error => raise Syntax(name~, diagnostics=error.to_string())
  }
  load_project_config_from_json(json, base_dir)
}

///|
pub fn load_project_config_from_json(
  json : Json,
  base_dir : String,
) -> ProjectConfig raise ProjectConfigError {
  decode_project_config(json, base_dir)
}

///|
fn read_project_config_text(path : String) -> String raise ProjectConfigError {
  @mbfs.read_file_to_string(path, encoding="utf8") catch {
    error => raise FileRead(path~, detail=@debug.render(Repr(error)))
  }
}

///|
fn decode_project_config(
  json : Json,
  base_dir : String,
) -> ProjectConfig raise ProjectConfigError {
  guard json is Object(fields) else { raise InvalidRoot }
  validate_object_fields(fields, "proton.project.json", [
    "backend", "frontend", "package",
  ])
  let backend = decode_backend(fields, base_dir)
  let frontend = decode_frontend(fields, base_dir)
  let package_config = decode_package(fields, base_dir)
  ProjectConfig::new(base_dir, backend~, frontend~, package_config~)
}

///|
fn decode_backend(
  fields : Map[String, Json],
  base_dir : String,
) -> ProjectBackendConfig? raise ProjectConfigError {
  match fields.get("backend") {
    None => None
    Some(Object(values)) => {
      validate_object_fields(values, "backend", ["path", "package"])
      Some(
        ProjectBackendConfig::new(
          rebase_project_path(
            required_string(values, "path", "backend.path"),
            base_dir,
            "backend.path",
          ),
          required_string(values, "package", "backend.package"),
        ),
      )
    }
    Some(_) =>
      raise InvalidField(
        path="proton.project.json.backend",
        expectation="must be an object",
      )
  }
}

///|
fn decode_frontend(
  fields : Map[String, Json],
  base_dir : String,
) -> ProjectFrontendConfig? raise ProjectConfigError {
  match fields.get("frontend") {
    None => None
    Some(Object(values)) => {
      validate_object_fields(values, "frontend", [
        "path", "dev_url", "before_dev", "before_build", "dist",
      ])
      let path = optional_rebased_path(
        values, "path", "frontend.path", base_dir,
      )
      let frontend_root = path.unwrap_or(base_dir)
      Some(
        ProjectFrontendConfig::new(
          path~,
          dev_url=optional_string(values, "dev_url", "frontend.dev_url"),
          before_dev=optional_string(
            values, "before_dev", "frontend.before_dev",
          ),
          before_build=optional_string(
            values, "before_build", "frontend.before_build",
          ),
          dist=optional_rebased_path(
            values, "dist", "frontend.dist", frontend_root,
          ),
        ),
      )
    }
    Some(_) =>
      raise InvalidField(
        path="proton.project.json.frontend",
        expectation="must be an object",
      )
  }
}

///|
fn decode_package(
  fields : Map[String, Json],
  base_dir : String,
) -> ProjectPackageConfig? raise ProjectConfigError {
  match fields.get("package") {
    None => None
    Some(Object(values)) => {
      validate_object_fields(values, "package", [
        "product_name", "identifier", "version", "formats", "icons", "resources",
        "sign", "url_schemes", "document_types", "output",
      ])
      let formats = optional_string_array(values, "formats", "package.formats")
      validate_formats(formats)
      let icons = rebased_paths(
        optional_string_array(values, "icons", "package.icons"),
        base_dir,
        "package.icons",
      )
      let resources = rebased_paths(
        optional_string_array(values, "resources", "package.resources"),
        base_dir,
        "package.resources",
      )
      let url_schemes = optional_string_array(
        values, "url_schemes", "package.url_schemes",
      )
      validate_url_schemes(url_schemes)
      Some(
        ProjectPackageConfig::new(
          required_string(values, "product_name", "package.product_name"),
          required_string(values, "identifier", "package.identifier"),
          required_string(values, "version", "package.version"),
          formats~,
          icons~,
          resources~,
          url_schemes~,
          sign=decode_sign(values, base_dir),
          document_types=decode_document_types(values),
          output=rebase_project_path(
            optional_string(values, "output", "package.output").unwrap_or(
              "dist",
            ),
            base_dir,
            "package.output",
          ),
        ),
      )
    }
    Some(_) =>
      raise InvalidField(
        path="proton.project.json.package",
        expectation="must be an object",
      )
  }
}

///|
fn decode_sign(
  fields : Map[String, Json],
  base_dir : String,
) -> ProjectSignConfig? raise ProjectConfigError {
  match fields.get("sign") {
    None => None
    Some(Object(values)) => {
      validate_object_fields(values, "package.sign", ["binaries"])
      let binaries = optional_string_array(
        values, "binaries", "package.sign.binaries",
      )
      for path in binaries {
        if path.contains("*") {
          raise InvalidField(
            path="package.sign.binaries",
            expectation="must not contain glob patterns",
          )
        }
      }
      Some(
        ProjectSignConfig::new(
          binaries=rebased_paths(binaries, base_dir, "package.sign.binaries"),
        ),
      )
    }
    Some(_) =>
      raise InvalidField(path="package.sign", expectation="must be an object")
  }
}

///|
fn decode_document_types(
  fields : Map[String, Json],
) -> Array[ProjectDocumentType] raise ProjectConfigError {
  match fields.get("document_types") {
    None => []
    Some(Array(items)) => {
      let result : Array[ProjectDocumentType] = []
      for item in items {
        guard item is Object(values) else {
          raise InvalidField(
            path="package.document_types",
            expectation="must be an array of objects",
          )
        }
        validate_object_fields(values, "package.document_types", [
          "name", "extensions", "role",
        ])
        let extensions = optional_string_array(
          values, "extensions", "package.document_types.extensions",
        )
        guard extensions.length() > 0 else {
          raise MissingField(path="package.document_types.extensions")
        }
        result.push(
          ProjectDocumentType::new(
            required_string(values, "name", "package.document_types.name"),
            extensions,
            role=optional_string(values, "role", "package.document_types.role").unwrap_or(
              "Viewer",
            ),
          ),
        )
      }
      result
    }
    Some(_) =>
      raise InvalidField(
        path="package.document_types",
        expectation="must be an array",
      )
  }
}

///|
fn rebased_paths(
  paths : Array[String],
  base_dir : String,
  label : String,
) -> Array[String] raise ProjectConfigError {
  paths.map(path => rebase_project_path(path, base_dir, label))
}

///|
fn validate_formats(formats : Array[String]) -> Unit raise ProjectConfigError {
  validate_unique_values(formats, "package.formats", [
    "app", "zip", "dmg", "nsis", "appimage",
  ])
}

///|
fn validate_url_schemes(
  schemes : Array[String],
) -> Unit raise ProjectConfigError {
  let seen : Map[String, Unit] = Map([])
  for scheme in schemes {
    let normalized = scheme.to_lower()
    guard url_scheme_is_valid(normalized) else {
      raise UnsupportedValue(path="package.url_schemes", value=scheme)
    }
    guard !seen.contains(normalized) else {
      raise DuplicateValue(path="package.url_schemes", value=scheme)
    }
    seen[normalized] = ()
  }
}

///|
fn url_scheme_is_valid(scheme : String) -> Bool {
  guard scheme.length() > 0 else { return false }
  for index, ch in scheme {
    let letter = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
    if index == 0 {
      guard letter else { return false }
    } else {
      guard letter ||
        (ch >= '0' && ch <= '9') ||
        ch == '+' ||
        ch == '-' ||
        ch == '.' else {
        return false
      }
    }
  }
  true
}

///|
fn validate_unique_values(
  values : Array[String],
  label : String,
  allowed : Array[String],
) -> Unit raise ProjectConfigError {
  let seen : Map[String, Unit] = Map([])
  for value in values {
    guard allowed.contains(value) else {
      raise UnsupportedValue(path=label, value~)
    }
    guard !seen.contains(value) else {
      raise DuplicateValue(path=label, value~)
    }
    seen[value] = ()
  }
}

///|
fn validate_object_fields(
  fields : Map[String, Json],
  label : String,
  allowed : Array[String],
) -> Unit raise ProjectConfigError {
  let errors : Array[ProjectConfigError] = []
  for key, _ in fields {
    if !allowed.contains(key) {
      errors.push(UnknownField(path=label + "." + key))
    }
  }
  raise_errors(errors)
}

///|
fn raise_errors(
  errors : Array[ProjectConfigError],
) -> Unit raise ProjectConfigError {
  if errors.length() == 1 {
    raise errors[0]
  }
  if errors.length() > 1 {
    raise Validation(errors~)
  }
}

///|
fn required_string(
  fields : Map[String, Json],
  key : String,
  label : String,
) -> String raise ProjectConfigError {
  match optional_string(fields, key, label) {
    Some(value) => value
    None => raise MissingField(path=label)
  }
}

///|
fn optional_string(
  fields : Map[String, Json],
  key : String,
  label : String,
) -> String? raise ProjectConfigError {
  match fields.get(key) {
    None => None
    Some(String(value)) if value.trim().to_owned() != "" => Some(value)
    Some(String(_)) =>
      raise InvalidField(path=label, expectation="must not be empty")
    Some(_) => raise InvalidField(path=label, expectation="must be a string")
  }
}

///|
fn optional_rebased_path(
  fields : Map[String, Json],
  key : String,
  label : String,
  base_dir : String,
) -> String? raise ProjectConfigError {
  optional_string(fields, key, label).map(path => {
    rebase_project_path(path, base_dir, label)
  })
}

///|
fn optional_string_array(
  fields : Map[String, Json],
  key : String,
  label : String,
) -> Array[String] raise ProjectConfigError {
  match fields.get(key) {
    None => []
    Some(Array(items)) =>
      items.map(item => {
        guard item is String(value) && value.trim().to_owned() != "" else {
          raise InvalidField(
            path=label,
            expectation="must be an array of non-empty strings",
          )
        }
        value
      })
    Some(_) =>
      raise InvalidField(path=label, expectation="must be an array of strings")
  }
}