// Copyright 2017 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package google.registry.ui.forms;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;

import com.google.common.base.Ascii;
import com.google.common.base.CharMatcher;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Range;
import com.google.common.collect.Streams;
import com.google.re2j.Pattern;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import javax.annotation.Detainted;
import javax.annotation.Nullable;
import javax.annotation.Tainted;
import javax.annotation.concurrent.Immutable;

/**
 * Declarative functional fluent form field converter / validator.
 *
 * <p>This class is responsible for converting arbitrary data, sent to us by the web browser, into
 * validated data structures that the server-side code can use. For example:<pre>
 *
 *   private enum Gender { MALE, FEMALE }
 *
 *   private static final FormField<String, String> NAME_FIELD = FormField.named("name")
 *       .matches("[a-z]+")
 *       .range(atMost(16))
 *       .required()
 *       .build();
 *
 *   private static final FormField<String, Gender> GENDER_FIELD = FormField.named("gender")
 *       .asEnum(Gender.class)
 *       .required()
 *       .build();
 *
 *   public Person makePerson(Map<String, String> params) {
 *     Person.Builder person = new Person.Builder();
 *     for (String name : NAME_FIELD.extract(params).asSet()) {
 *       person.setName(name);
 *     }
 *     for (Gender name : GENDER_FIELD.extract(params).asSet()) {
 *       person.setGender(name);
 *     }
 *     return person.build();
 *   }</pre>
 *
 * <p>This class provides <b>full type-safety</b> <i>if and only if</i> you statically initialize
 * your FormField objects and write a unit test that causes the class to be loaded.
 *
 * <h3>Exception Handling</h3>
 *
 * <p>When values passed to {@link #convert} or {@link #extract} don't meet the contract,
 * {@link FormFieldException} will be thrown, which provides the field name and a short error
 * message that's safe to pass along to the client.
 *
 * <p>You can safely throw {@code FormFieldException} from within your validator functions, and
 * the field name will automatically be propagated into the exception object for you.
 *
 * <p>In situations when you're validating lists or maps, you'll end up with a hierarchical field
 * naming structure. For example, if you were validating a list of maps, an error generated by the
 * {@code bar} field of the fifth item in the {@code foo} field would have a fully-qualified field
 * name of: {@code foo[5][bar]}.
 *
 * <h3>Library Definitions</h3>
 *
 * <p>You should never assign a partially constructed {@code FormField.Builder} to a variable or
 * constant. Instead, you should use {@link #asBuilder()} or {@link #asBuilderNamed(String)}.
 *
 * <p>Here is an example of how you might go about defining library definitions:<pre>
 *
 *   final class FormFields {
 *     private static final FormField<String, String> COUNTRY_CODE =
 *         FormField.named("countryCode")
 *             .range(Range.singleton(2))
 *             .uppercased()
 *             .in(ImmutableSet.copyOf(Locale.getISOCountries()))
 *             .build();
 *   }
 *
 *   final class Form {
 *     private static final FormField<String, String> COUNTRY_CODE_FIELD =
 *         FormFields.COUNTRY_CODE.asBuilder()
 *             .required()
 *             .build();
 *   }</pre>
 *
 * @param <I> input value type
 * @param <O> output value type
 */
@Immutable
public final class FormField<I, O> {

  private final String name;
  private final Class<I> typeIn;
  private final Class<O> typeOut;
  private final Function<I, O> converter;

  private FormField(String name, Class<I> typeIn, Class<O> typeOut, Function<I, O> converter) {
    this.name = name;
    this.typeIn = typeIn;
    this.typeOut = typeOut;
    this.converter = converter;
  }

  /** Returns an optional string form field named {@code name}. */
  public static Builder<String, String> named(String name) {
    return named(name, String.class);
  }

  /** Returns an optional form field named {@code name} with a specific {@code inputType}. */
  public static <T> Builder<T, T> named(String name, Class<T> typeIn) {
    checkArgument(!name.isEmpty());
    return new Builder<>(name, checkNotNull(typeIn), typeIn, x -> x);
  }

  /**
   * Returns a form field builder for validating JSON nested maps.
   *
   * <p>Here's an example of how you'd use this feature:
   *
   * <pre>
   *   private static final FormField&lt;String, String&gt; REGISTRAR_NAME_FIELD =
   *       FormField.named("name")
   *           .emptyToNull()
   *           .required()
   *           .build();
   *
   *   private static final FormField&lt;Map&lt;String, ?&gt;, Registrar&gt; REGISTRAR_FIELD =
   *       FormField.mapNamed("registrar")
   *           .transform(Registrar.class, new Function&lt;Map&lt;String, ?&gt;, Registrar&gt;() {
   *             &#064;Nullable
   *             &#064;Override
   *             public Registrar apply(&#064;Nullable Map&lt;String, ?&gt; params) {
   *               Registrar.Builder builder = new Registrar.Builder();
   *               for (String name : REGISTRAR_NAME_FIELD.extractUntyped(params).asSet()) {
   *                builder.setName(name);
   *               }
   *               return builder.build();
   *             }})
   *           .build();</pre>
   *
   * <p>When a {@link FormFieldException} is thrown, it'll be propagated to create a fully-qualified
   * field name. For example, if the JSON input is <pre>{registrar: {name: ""}}</pre> then the
   * {@link FormFieldException#getFieldName() field name} will be {@code registrar.name}.
   */
  public static Builder<Map<String, ?>, Map<String, ?>> mapNamed(String name) {
    @SuppressWarnings("unchecked")
    Class<Map<String, ?>> typeIn = (Class<Map<String, ?>>) (Class<?>) Map.class;
    return named(name, typeIn);
  }

  /** Returns the name of this field. */
  public String name() {
    return name;
  }

  /**
   * Convert and validate a raw user-supplied value.
   *
   * @throws FormFieldException if value does not meet expected contracts.
   */
  @Detainted
  public Optional<O> convert(@Tainted @Nullable I value) {
    try {
      return Optional.ofNullable(converter.apply(value));
    } catch (FormFieldException e) {
      throw e.propagate(name);
    }
  }

  /**
   * Convert and validate a raw user-supplied value from a map.
   *
   * <p>This is the same as saying: {@code field.convert(valueMap.get(field.name())}
   *
   * @throws FormFieldException if value does not meet expected contracts.
   */
  @Detainted
  public Optional<O> extract(@Tainted Map<String, I> valueMap) {
    return convert(valueMap.get(name));
  }

  /**
   * Convert and validate a raw user-supplied value from an untyped JSON map.
   *
   * @throws FormFieldException if value is wrong type or does not meet expected contracts.
   */
  @Detainted
  public Optional<O> extractUntyped(@Tainted Map<String, ?> jsonMap) {
    Object value = jsonMap.get(name);
    I castedValue;
    try {
      castedValue = typeIn.cast(value);
    } catch (ClassCastException e) {
      throw new FormFieldException(String.format("Type error: got: %s, expected: %s",
          value.getClass().getSimpleName(),
          typeIn.getSimpleName())).propagate(name);
    }
    return convert(castedValue);
  }

  /**
   * Returns a builder of this object, which can be used to further restrict validation.
   *
   * @see #asBuilderNamed(String)
   */
  public Builder<I, O> asBuilder() {
    return new Builder<>(name, typeIn, typeOut, converter);
  }

  /** Same as {@link #asBuilder()} but changes the field name. */
  public Builder<I, O> asBuilderNamed(String newName) {
    checkArgument(!newName.isEmpty());
    return new Builder<>(newName, typeIn, typeOut, converter);
  }

  /**
   * Mutable builder for {@link FormField}.
   *
   * @param <I> input value type
   * @param <O> output value type
   */
  public static final class Builder<I, O> {
    private final String name;
    private final Class<I> typeIn;
    private final Class<O> typeOut;
    private Function<I, O> converter;

    private Builder(String name, Class<I> typeIn, Class<O> typeOut, Function<I, O> converter) {
      this.name = name;
      this.typeIn = typeIn;
      this.typeOut = typeOut;
      this.converter = converter;
    }

    /** Causes {@code defaultValue} to be substituted if value is {@code null}. */
    public Builder<I, O> withDefault(O defaultValue) {
      return transform(new DefaultFunction<>(checkNotNull(defaultValue)));
    }

    /** Ensure value is not {@code null}. */
    public Builder<I, O> required() {
      return transform(Builder::checkNotNullTransform);
    }

    /**
     * Transform empty values into {@code null}.
     *
     * @throws IllegalStateException if current output type is not a {@link CharSequence} or
     *     {@link Collection}.
     */
    public Builder<I, O> emptyToNull() {
      checkState(CharSequence.class.isAssignableFrom(typeOut)
          || Collection.class.isAssignableFrom(typeOut));
      return transform(
          input ->
              ((input instanceof CharSequence) && (((CharSequence) input).length() == 0))
                      || ((input instanceof Collection) && ((Collection<?>) input).isEmpty())
                  ? null
                  : input);
    }

    /**
     * Modify {@link String} input to remove whitespace around the sides.
     *
     * <p>{@code null} values are passed through.
     *
     * @throws IllegalStateException if current output type is not a String.
     */
    public Builder<I, String> trimmed() {
      checkState(String.class.isAssignableFrom(typeOut));
      @SuppressWarnings("unchecked")
      Function<O, String> trimFunction =
          (Function<O, String>)
              ((Function<String, String>) input -> input != null ? input.trim() : null);
      return transform(String.class, trimFunction);
    }

    /**
     * Modify {@link String} input to be uppercase.
     *
     * <p>{@code null} values are passed through.
     *
     * @throws IllegalStateException if current output type is not a String.
     */
    public Builder<I, String> uppercased() {
      checkState(String.class.isAssignableFrom(typeOut));
      @SuppressWarnings("unchecked")
      Function<O, String> funk =
          (Function<O, String>)
              ((Function<String, String>)
                  input -> input != null ? input.toUpperCase(Locale.ENGLISH) : null);
      return transform(String.class, funk);
    }

    /**
     * Modify {@link String} input to be lowercase.
     *
     * <p>{@code null} values are passed through.
     *
     * @throws IllegalStateException if current output type is not a String.
     */
    public Builder<I, String> lowercased() {
      checkState(String.class.isAssignableFrom(typeOut));
      @SuppressWarnings("unchecked")
      Function<O, String> funk =
          (Function<O, String>)
              ((Function<String, String>)
                  input -> input != null ? input.toLowerCase(Locale.ENGLISH) : null);
      return transform(String.class, funk);
    }

    /**
     * Ensure input matches {@code pattern}.
     *
     * <p>{@code null} values are passed through.
     *
     * @param pattern is used to validate the user input. It matches against the whole string, so
     *     you don't need to use the ^$ characters.
     * @param errorMessage is a helpful error message, which should include an example. If this is
     *     not provided, a default error message will be shown that includes the regexp pattern.
     * @throws IllegalStateException if current output type is not a {@link CharSequence}.
     * @see #matches(Pattern)
     */
    public Builder<I, O> matches(Pattern pattern, @Nullable String errorMessage) {
      checkState(CharSequence.class.isAssignableFrom(typeOut));
      return transform(
          new MatchesFunction<>(checkNotNull(pattern), Optional.ofNullable(errorMessage)));
    }

    /** Alias for {@link #matches(Pattern, String) matches(pattern, null)} */
    public Builder<I, O> matches(Pattern pattern) {
      return matches(pattern, null);
    }

    /**
     * Removes all characters not in {@code matcher}.
     *
     * <p>{@code null} values are passed through.
     *
     * @param matcher indicates which characters are to be retained
     * @throws IllegalStateException if current output type is not a {@link CharSequence}
     */
    public Builder<I, String> retains(CharMatcher matcher) {
      checkState(CharSequence.class.isAssignableFrom(typeOut));
      @SuppressWarnings("unchecked")  // safe due to checkState call
      Function<O, String> function =
          (Function<O, String>) new RetainFunction(checkNotNull(matcher));
      return transform(String.class, function);
    }

    /**
     * Enforce value length/size/value is within {@code range}.
     *
     * <p>The following input value types are supported:
     *
     * <ul>
     * <li>{@link CharSequence}: Length must be within {@code range}.
     * <li>{@link Collection}: Size must be within {@code range}.
     * <li>{@link Number}: Value must be within {@code range}.
     * </ul>
     *
     * <p>{@code null} values are passed through. Please note that setting a lower bound on your
     * range does not imply {@link #required()}, as range checking only applies to non-{@code null}
     * values.
     *
     * @throws IllegalStateException if current output type is not one of the above types.
     */
    public Builder<I, O> range(Range<Integer> range) {
      checkState(CharSequence.class.isAssignableFrom(typeOut)
          || Collection.class.isAssignableFrom(typeOut)
          || Number.class.isAssignableFrom(typeOut));
      return transform(new RangeFunction<>(checkNotNull(range)));
    }

    /**
     * Enforce value be a member of {@code values}.
     *
     * <p>{@code null} values are passed through.
     *
     * @throws IllegalArgumentException if {@code values} is empty.
     */
    public Builder<I, O> in(Set<O> values) {
      checkArgument(!values.isEmpty());
      return transform(new InFunction<>(values));
    }

    /**
     * Performs arbitrary type transformation from {@code O} to {@code T}.
     *
     * <p>Your {@code transform} function is expected to pass-through {@code null} values as a
     * no-op, since it's up to {@link #required()} to block them. You might also want to consider
     * using a try block that rethrows exceptions as {@link FormFieldException}.
     *
     * <p>Here's an example of how you'd convert from String to Integer:
     *
     * <pre>
     *   FormField.named("foo", String.class)
     *       .transform(Integer.class, new Function&lt;String, Integer&gt;() {
     *         &#064;Nullable
     *         &#064;Override
     *         public Integer apply(&#064;Nullable String input) {
     *           try {
     *             return input != null ? Integer.parseInt(input) : null;
     *           } catch (IllegalArgumentException e) {
     *             throw new FormFieldException("Invalid number.", e);
     *           }
     *         }})
     *       .build();</pre>
     *
     * @see #transform(Function)
     */
    public <T> Builder<I, T> transform(Class<T> newType, Function<O, T> transform) {
      return new Builder<>(
          name, typeIn, checkNotNull(newType), this.converter.andThen(checkNotNull(transform)));
    }

    /**
     * Manipulates values without changing type.
     *
     * <p>Please see {@link #transform(Class, Function)} for information about the contract to
     * which {@code transform} is expected to conform.
     */
    public Builder<I, O> transform(Function<O, O> transform) {
      this.converter = this.converter.andThen(checkNotNull(transform));
      return this;
    }

    /**
     * Uppercases value and converts to an enum field of {@code enumClass}.
     *
     * <p>{@code null} values are passed through.
     *
     * @throws IllegalArgumentException if {@code enumClass} is not an enum class.
     * @throws IllegalStateException if current output type is not a String.
     */
    public <C extends Enum<C>> Builder<I, C> asEnum(Class<C> enumClass) {
      checkArgument(enumClass.isEnum());
      checkState(String.class.isAssignableFrom(typeOut));
      return transform(enumClass, new ToEnumFunction<>(enumClass));
    }

    /**
     * Turns this form field into something that processes lists.
     *
     * <p>The current object definition will be applied to each item in the list. If a
     * {@link FormFieldException} is thrown when processing an item, then its
     * {@link FormFieldException#getFieldName() fieldName} will be rewritten to include the index,
     * e.g. {@code name} becomes {@code name[0]}.
     *
     * <p>The outputted list will be an {@link ImmutableList}. This is not reflected in the generic
     * typing for the sake of brevity.
     *
     * <p>A {@code null} value for list will be passed through. List items that convert to
     * {@code null} will be discarded (since {@code ImmutableList} does not permit {@code null}
     * values).
     */
    public Builder<List<I>, List<O>> asList() {
      @SuppressWarnings("unchecked") Class<List<I>> in = (Class<List<I>>) (Class<I>) List.class;
      @SuppressWarnings("unchecked") Class<List<O>> out = (Class<List<O>>) (Class<O>) List.class;
      return new Builder<>(name, in, out, new ToListFunction<>(build()));
    }

    /**
     * Turns this form field into a split string list that applies itself to each item.
     *
     * <p>The behavior of this method is counter-intuitive. It behaves similar to {@link #asList()}
     * in the sense that all transforms specified <i>before</i> this method will be applied to the
     * individual resulting list items.
     *
     * <p>For example, to turn a comma-delimited string into an enum list:<pre>   {@code
     *
     *   private static final FormField<String, List<State>> STATES_FIELD =
     *       FormField.named("states")
     *            .uppercased()
     *            .asEnum(State.class)
     *            .asList(Splitter.on(',').omitEmptyStrings().trimResults())
     *            .build();}</pre>
     *
     * <p>You'll notice that the transforms specified before this method are applied to each list
     * item. However unlike {@link #asList()}, if an error is thrown on an individual item, then
     * {@link FormFieldException#getFieldName()} will <i>not</i> contain the index.
     *
     * @throws IllegalStateException If either the current input type isn't String.
     */
    public Builder<String, List<O>> asList(Splitter splitter) {
      checkNotNull(splitter);
      checkState(String.class.isAssignableFrom(typeIn));
      @SuppressWarnings("unchecked") Class<List<O>> out = (Class<List<O>>) (Class<O>) List.class;
      @SuppressWarnings("unchecked") FormField<String, O> inField = (FormField<String, O>) build();
      return new Builder<>(name, String.class, out, new SplitToListFunction<>(inField, splitter));
    }

    /**
     * Same as {@link #asList()} but outputs an {@link ImmutableSet} instead.
     *
     * @throws IllegalStateException if you called asList() before calling this method.
     */
    public Builder<List<I>, Set<O>> asSet() {
      checkState(!List.class.isAssignableFrom(typeOut));
      @SuppressWarnings("unchecked")
      Class<Set<O>> setOut = (Class<Set<O>>) (Class<O>) Set.class;
      @SuppressWarnings("unchecked")
      Function<List<O>, Set<O>> toSetFunction =
          (Function<List<O>, Set<O>>)
              (Function<O, O>)
                  ((Function<List<Object>, Set<Object>>)
                      input -> input != null ? ImmutableSet.copyOf(input) : null);
      return asList().transform(setOut, toSetFunction);
    }

    /**
     * Same as {@link #asList(Splitter)} but outputs an {@link ImmutableSet} instead.
     *
     * @throws IllegalStateException If the current input type isn't String.
     */
    public Builder<String, Set<O>> asSet(Splitter splitter) {
      checkNotNull(splitter);
      checkState(String.class.isAssignableFrom(typeIn));
      @SuppressWarnings("unchecked") Class<Set<O>> out = (Class<Set<O>>) (Class<O>) Set.class;
      @SuppressWarnings("unchecked") FormField<String, O> inField = (FormField<String, O>) build();
      return new Builder<>(name, String.class, out, new SplitToSetFunction<>(inField, splitter));
    }

    /** Creates a new {@link FormField} instance. */
    public FormField<I, O> build() {
      return new FormField<>(name, typeIn, typeOut, converter);
    }

    private static <O> O checkNotNullTransform(@Nullable O input) {
      if (input == null) {
        throw new FormFieldException("This field is required.");
      }
      return input;
    }

    private static final class DefaultFunction<O> implements Function<O, O> {
      private final O defaultValue;

      DefaultFunction(O defaultValue) {
        this.defaultValue = defaultValue;
      }

      @Nullable
      @Override
      public O apply(@Nullable O input) {
        return input != null ? input : defaultValue;
      }
    }

    private static final class RangeFunction<O> implements Function<O, O> {
      private final Range<Integer> range;

      RangeFunction(Range<Integer> range) {
        this.range = range;
      }

      @Nullable
      @Override
      public O apply(@Nullable O input) {
        if (input == null) {
          return null;
        }
        if (input instanceof CharSequence) {
          checkRangeContains(range, ((CharSequence) input).length(), "Number of characters");
        } else if (input instanceof Collection) {
          checkRangeContains(range, ((Collection<?>) input).size(), "Number of items");
        } else if (input instanceof Number) {
          checkRangeContains(range, ((Number) input).intValue(), "Value");
        } else {
          throw new AssertionError();
        }
        return input;
      }

      private void checkRangeContains(Range<Integer> range, int value, String message) {
        if (!range.contains(value)) {
          throw new FormFieldException(
              String.format("%s (%,d) not in range %s", message, value, range));
        }
      }
    }

    private static final class InFunction<O> implements Function<O, O> {
      private final Set<O> values;

      InFunction(Set<O> values) {
        this.values = values;
      }

      @Nullable
      @Override
      public O apply(@Nullable O input) {
        if (input == null) {
          return null;
        }
        if (!values.contains(input)) {
          throw new FormFieldException("Unrecognized value.");
        }
        return input;
      }
    }

    private static final class MatchesFunction<O> implements Function<O, O> {
      private final Pattern pattern;
      private final Optional<String> errorMessage;

      MatchesFunction(Pattern pattern, Optional<String> errorMessage) {
        this.pattern = pattern;
        this.errorMessage = errorMessage;
      }

      @Nullable
      @Override
      public O apply(@Nullable O input) {
        if (input == null) {
          return null;
        }
        if (!pattern.matcher((CharSequence) input).matches()) {
          throw new FormFieldException(errorMessage.orElse("Must match pattern: " + pattern));
        }
        return input;
      }
    }

    private static final class RetainFunction implements Function<CharSequence, String> {
      private final CharMatcher matcher;

      RetainFunction(CharMatcher matcher) {
        this.matcher = matcher;
      }

      @Nullable
      @Override
      public String apply(@Nullable CharSequence input) {
        if (input == null) {
          return null;
        }
        return matcher.retainFrom(input);
      }
    }

    private static final class ToEnumFunction<O, C extends Enum<C>> implements Function<O, C> {
      private final Class<C> enumClass;

      ToEnumFunction(Class<C> enumClass) {
        this.enumClass = enumClass;
      }

      @Nullable
      @Override
      public C apply(@Nullable O input) {
        try {
          return input != null ? Enum.valueOf(enumClass, Ascii.toUpperCase((String) input)) : null;
        } catch (IllegalArgumentException e) {
          throw new FormFieldException(
              String.format("Enum %s does not contain '%s'", enumClass.getSimpleName(), input));
        }
      }
    }

    private static final class ToListFunction<I, O> implements Function<List<I>, List<O>> {
      private final FormField<I, O> itemField;

      ToListFunction(FormField<I, O> itemField) {
        this.itemField = itemField;
      }

      @Nullable
      @Override
      public List<O> apply(@Nullable List<I> input) {
        if (input == null) {
          return null;
        }
        ImmutableList.Builder<O> builder = new ImmutableList.Builder<>();
        for (int i = 0; i < input.size(); i++) {
          I inputItem = itemField.typeIn.cast(input.get(i));
          O outputItem;
          try {
            outputItem = itemField.converter.apply(inputItem);
          } catch (FormFieldException e) {
            throw e.propagate(i);
          }
          if (outputItem != null) {
            builder.add(outputItem);
          }
        }
        return builder.build();
      }
    }

    private static final class SplitToListFunction<O> implements Function<String, List<O>> {
      private final FormField<String, O> itemField;
      private final Splitter splitter;

      SplitToListFunction(FormField<String, O> itemField, Splitter splitter) {
        this.itemField = itemField;
        this.splitter = splitter;
      }

      @Nullable
      @Override
      public List<O> apply(@Nullable String input) {
        return input == null
            ? null
            : Streams.stream(splitter.split(input))
                .map(itemField.converter)
                .collect(toImmutableList());
      }
    }

    private static final class SplitToSetFunction<O> implements Function<String, Set<O>> {
      private final FormField<String, O> itemField;
      private final Splitter splitter;

      SplitToSetFunction(FormField<String, O> itemField, Splitter splitter) {
        this.itemField = itemField;
        this.splitter = splitter;
      }

      @Nullable
      @Override
      public Set<O> apply(@Nullable String input) {
        return input == null
            ? null
            : Streams.stream(splitter.split(input))
                .map(itemField.converter)
                .collect(toImmutableSet());
      }
    }
  }
}
