// Enum conversion (upstream enums.rs, minus code emission).

///|
/// `oneOf`/`anyOf` with exactly one non-null subschema is an `Option`.
fn TypeSpace::maybe_option(
  self : TypeSpace,
  type_name : Name,
  metadata : @schema.Metadata?,
  subschemas : Array[@schema.Schema],
) -> TypeEntry? raise TypifyError {
  if subschemas.length() == 1 {
    return None
  }
  let non_nulls = subschemas.filter(s => !is_null_schema(s))
  guard non_nulls is [non_null] else { return None }
  ok(() => self.convert_option(type_name, metadata, non_null).0)
}

///|
priv enum ProtoVariant {
  Simple(String, String?)
  Typed(String, @schema.Schema, String?)
}

///|
fn ProtoVariant::name(self : ProtoVariant) -> String {
  match self {
    Simple(n, _) | Typed(n, _, _) => n
  }
}

///|
/// The string or null instance type restriction for simple variants.
fn string_or_untyped(o : @schema.SchemaObject) -> Bool {
  match o.instance_type {
    Some(Single(String)) | None => true
    _ => false
  }
}

///|
fn TypeSpace::maybe_externally_tagged_enum(
  self : TypeSpace,
  type_name : Name,
  original_schema : @schema.Schema,
  enum_metadata : @schema.Metadata?,
  subschemas : Array[@schema.Schema],
) -> TypeEntry? raise TypifyError {
  let loose = F_META | F_TYPE | F_NUM | F_STR | F_ARR | F_OBJ
  let protos : Array[ProtoVariant] = []
  for schema in subschemas {
    match schema {
      Bool(true) => raise panic_with("internal error: entered unreachable code")
      Bool(false) => raise panic_with("not yet implemented")
      Object(o) if shape(o, some=F_ENUM, any=loose) => {
        if !string_or_untyped(o) {
          return None
        }
        for value in o.enum_values.unwrap() {
          guard value.as_str() is Some(name) else { return None }
          protos.push(Simple(name, metadata_description(o.metadata)))
        }
      }
      Object(o) if shape(o, some=F_CONST, any=loose) => {
        if !string_or_untyped(o) {
          return None
        }
        guard o.const_value.unwrap().as_str() is Some(name) else { return None }
        protos.push(Simple(name, metadata_description(o.metadata)))
      }
      other =>
        match get_object(other) {
          Some((metadata, v)) if v.max_properties is None &&
            v.min_properties is None &&
            v.required.length() == 1 &&
            v.properties.length() == 1 &&
            v.pattern_properties.is_empty() &&
            v.property_names is None => {
            let (prop_name, prop_type) = v.properties.first().unwrap()
            if !v.required.contains(prop_name) {
              raise panic_with("assertion failed: required.contains(prop_name)")
            }
            protos.push(
              Typed(prop_name, prop_type, metadata_description(metadata)),
            )
          }
          _ => return None
        }
    }
  }
  if !unique(protos.map(p => p.name())) {
    return None
  }
  let mut deny_unknown_fields = false
  let variants = []
  for proto in protos {
    match proto {
      Simple(name, description) =>
        variants.push(Variant::new(name, description, Simple))
      Typed(name, schema, description) => {
        guard ok(() => self.external_variant(type_name.append(name), schema))
          is Some((details, deny)) else {
          return None
        }
        deny_unknown_fields = deny_unknown_fields || deny
        variants.push(Variant::new(name, description, details))
      }
    }
  }
  Some(
    TypeEntryEnum::from_metadata(
      self,
      type_name,
      enum_metadata,
      External,
      variants,
      deny_unknown_fields,
      original_schema,
    ),
  )
}

///|
/// Variant details for a variant's payload schema, and whether it denies
/// unknown fields.
fn TypeSpace::external_variant(
  self : TypeSpace,
  prop_type_name : Name,
  variant_schema : @schema.Schema,
) -> (VariantDetails, Bool) raise TypifyError {
  let (ty, _) = self.convert_schema(prop_type_name, variant_schema)
  match ty.details {
    Tuple(types) => (Tuple(types), false)
    Unit => (Simple, false)
    Struct({ properties, deny_unknown_fields, .. }) =>
      (Struct(properties), deny_unknown_fields)
    _ => (Item(self.assign_type(ty)), false)
  }
}

///|
fn TypeSpace::maybe_internally_tagged_enum(
  self : TypeSpace,
  type_name : Name,
  original_schema : @schema.Schema,
  metadata : @schema.Metadata?,
  subschemas : Array[@schema.Schema],
) -> TypeEntry? raise TypifyError {
  // Required, constant-string properties of each subschema.
  let sets : Array[@collections.StrMap[@collections.StrSet]] = subschemas.map(schema => {
    let m = @collections.StrMap::new()
    if get_object(schema) is Some((_, v)) {
      for prop_name, prop_type in v.properties {
        if v.required.contains(prop_name) &&
          constant_string_value(prop_type) is Some(value) {
          m.set(prop_name, @collections.StrSet::from_array([value]))
        }
      }
    }
    m
  })
  guard sets is [first, .. rest] else { return None }
  let mut acc = first
  for b in rest {
    let next = @collections.StrMap::new()
    for prop, a_values in acc {
      match b.get(prop) {
        Some(b_values) if !a_values.iter().any(x => b_values.contains(x)) =>
          next.set(prop, a_values.union(b_values))
        _ => ()
      }
    }
    acc = next
  }
  guard acc.first() is Some((tag, _)) else { return None }
  let mut deny_unknown_fields = false
  let result = ok(() => {
    let variants = []
    for schema in subschemas {
      guard get_object(schema) is Some((variant_metadata, v)) else {
        raise panic_with("internal error: entered unreachable code")
      }
      match v.additional_properties {
        Some(Bool(false)) => deny_unknown_fields = true
        None => ()
        _ => raise panic_with("internal error: entered unreachable code")
      }
      variants.push(
        self.internal_variant(
          get_type_name(type_name, variant_metadata),
          variant_metadata,
          v,
          tag,
        ),
      )
    }
    variants
  })
  guard result is Some(variants) else { return None }
  Some(
    TypeEntryEnum::from_metadata(
      self,
      type_name,
      metadata,
      Internal(tag~),
      variants,
      deny_unknown_fields,
      original_schema,
    ),
  )
}

///|
fn TypeSpace::internal_variant(
  self : TypeSpace,
  enum_type_name : String?,
  metadata : @schema.Metadata?,
  validation : @schema.ObjectValidation,
  tag : String,
) -> Variant raise TypifyError {
  if validation.properties.length() == 1 {
    let (tag_name, schema) = validation.properties.first().unwrap()
    guard constant_string_value(schema) is Some(variant_name) else {
      raise panic_with("called `Option::unwrap()` on a `None` value")
    }
    if tag_name != tag || validation.required.length() != 1 {
      raise panic_with("assertion failed: internal tag shape")
    }
    Variant::new(variant_name, None, Simple)
  } else {
    guard validation.properties.get(tag).bind(constant_string_value)
      is Some(variant_name) else {
      raise panic_with("called `Option::unwrap()` on a `None` value")
    }
    let properties = validation.properties.copy()
    ignore(properties.remove(tag))
    let required = validation.required.copy()
    ignore(required.remove(tag))
    let new_validation = { ..validation, properties, required, }
    let (props, _) = self.struct_members(enum_type_name, new_validation)
    Variant::new(
      variant_name,
      metadata_title_and_description(metadata),
      Struct(props),
    )
  }
}

///|
fn TypeSpace::maybe_adjacently_tagged_enum(
  self : TypeSpace,
  type_name : Name,
  original_schema : @schema.Schema,
  metadata : @schema.Metadata?,
  subschemas : Array[@schema.Schema],
) -> TypeEntry? raise TypifyError {
  let prop_sets : Array[(@collections.StrSet, @collections.StrSet)] = []
  for schema in subschemas {
    match get_object(schema) {
      Some((_, v)) if v.properties.length() == v.required.length() => {
        let constants = @collections.StrSet::new()
        for name, t in v.properties {
          if constant_string_value(t) is Some(_) {
            constants.add(name)
          }
        }
        prop_sets.push(
          (constants, @collections.StrSet::from_array(v.properties.keys())),
        )
      }
      _ => return None
    }
  }
  guard prop_sets is [first, .. rest] else { return None }
  let mut tag_props = first.0
  let mut content_props = first.1
  for p in rest {
    tag_props = @collections.StrSet::from_array(
      tag_props.to_array().filter(x => p.0.contains(x)),
    )
    content_props = content_props.union(p.1)
  }
  if tag_props.length() != 1 || content_props.length() != 2 {
    return None
  }
  let tag = tag_props.to_array()[0]
  guard content_props.to_array().filter(x => !tag_props.contains(x))
    is [content, ..] else {
    return None
  }
  let mut deny_unknown_fields = false
  let result = ok(() => {
    let variants = []
    for schema in subschemas {
      guard get_object(schema) is Some((variant_metadata, v)) else {
        raise panic_with("internal error: entered unreachable code")
      }
      let (variant, deny) = self.adjacent_variant(
        type_name, variant_metadata, v, tag, content,
      )
      deny_unknown_fields = deny_unknown_fields || deny
      variants.push(variant)
    }
    variants
  })
  guard result is Some(variants) else { return None }
  Some(
    TypeEntryEnum::from_metadata(
      self,
      type_name,
      metadata,
      Adjacent(tag~, content~),
      variants,
      deny_unknown_fields,
      original_schema,
    ),
  )
}

///|
fn TypeSpace::adjacent_variant(
  self : TypeSpace,
  enum_type_name : Name,
  metadata : @schema.Metadata?,
  validation : @schema.ObjectValidation,
  tag : String,
  content : String,
) -> (Variant, Bool) raise TypifyError {
  if validation.properties.length() == 1 {
    let (tag_name, schema) = validation.properties.first().unwrap()
    guard constant_string_value(schema) is Some(variant_name) else {
      raise panic_with("called `Option::unwrap()` on a `None` value")
    }
    if tag_name != tag || validation.required.length() != 1 {
      raise panic_with("assertion failed: adjacent tag shape")
    }
    (Variant::new(variant_name, None, Simple), false)
  } else {
    guard validation.properties.get(tag).bind(constant_string_value)
      is Some(variant_name) else {
      raise panic_with("called `Option::unwrap()` on a `None` value")
    }
    let sub_type_name = match enum_type_name {
      Required(_) => enum_type_name.append(content)
      _ => enum_type_name.append(variant_name)
    }
    guard validation.properties.get(content) is Some(content_schema) else {
      raise panic_with("called `Option::unwrap()` on a `None` value")
    }
    let (details, deny) = self.external_variant(sub_type_name, content_schema)
    (
      Variant::new(
        variant_name,
        metadata_title_and_description(metadata),
        details,
      ),
      deny,
    )
  }
}

///|
/// An enum with one variant per subschema; variant names are inferred where
/// possible (common prefixes removed), otherwise `VariantN`.
fn TypeSpace::untagged_enum(
  self : TypeSpace,
  type_name : Name,
  original_schema : @schema.Schema,
  metadata : @schema.Metadata?,
  subschemas : Array[@schema.Schema],
) -> TypeEntry raise TypifyError {
  let tmp_type_name = get_type_name(type_name, metadata)
  let fallback = () => Array::makei(subschemas.length(), i => "Variant\{i}")
  let variant_names = {
    let names = []
    let mut all_named = true
    for s in subschemas {
      match schema_is_named(s) {
        Some(n) => names.push(n)
        None => {
          all_named = false
          break
        }
      }
    }
    if !all_named || !unique(names) {
      fallback()
    } else {
      let mut prefix = names[0]
      for i in 1.. Suggested(name)
      None => Unknown
    }
    let (details, deny) = self.external_variant(
      base.append(variant_name),
      schema,
    )
    deny_unknown_fields = deny_unknown_fields || deny
    variants.push(Variant::new(variant_name, None, details))
  }
  TypeEntryEnum::from_metadata(
    self,
    type_name,
    metadata,
    Untagged,
    variants,
    deny_unknown_fields,
    original_schema,
  )
}

///|
/// Whether the schema can only be satisfied by null.
fn is_null_schema(schema : @schema.Schema) -> Bool {
  match schema {
    Object({ instance_type: Some(Single(t)), .. }) => t == Null
    Object({ instance_type: Some(Vec(types)), .. }) =>
      types.iter().all(t => t == Null)
    Object({ const_value: Some(v), .. }) => v.is_null()
    Object({ enum_values: Some(values), .. }) =>
      values.iter().all(v => v.is_null())
    _ => false
  }
}

///|
/// The common prefix of two names at word boundaries, in PascalCase.
fn get_common_prefix(name : String, prefix : String) -> String {
  let a = @heck.to_kebab_case(name).split("-").collect()
  let b = @heck.to_kebab_case(prefix).split("-").collect()
  let common = []
  for i in 0..