c3r-cli-spark/src/main/java/com/amazonaws/c3r/spark/io/schema/InteractiveSchemaGenerator.java [86:652]:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    private final String targetJsonFile;

    /**
     * Console input from user.
     */
    private final BufferedReader consoleInput;

    /**
     * Console output stream.
     */
    private final PrintStream consoleOutput;

    /**
     * Whether cleartext columns possible for this schema.
     */
    private final boolean allowCleartextColumns;


    /**
     * Sets up the schema generator to run in interactive mode. Makes I/O connections to console, processes header information and
     * initializes preprocessing state.
     *
     * @param sourceHeaders     Column names in data file if they exist, otherwise {@code null}
     * @param sourceColumnTypes The column types in the file in the order they appear
     * @param targetJsonFile    Where schema should be written
     * @param consoleInput      Connection to input stream (i.e., input from user)
     * @param consoleOutput     Connection to output stream (i.e., output for user)
     * @param clientSettings    Collaboration's client settings if provided, else {@code null}
     * @throws C3rIllegalArgumentException If input sizes are inconsistent
     */
    @Builder
    @SuppressFBWarnings({"EI_EXPOSE_REP", "EI_EXPOSE_REP2"})
    private InteractiveSchemaGenerator(final List<ColumnHeader> sourceHeaders,
                                       @NonNull final List<ClientDataType> sourceColumnTypes,
                                       @NonNull final String targetJsonFile,
                                       final BufferedReader consoleInput,
                                       final PrintStream consoleOutput,
                                       final ClientSettings clientSettings) {
        if (sourceHeaders != null && sourceHeaders.size() != sourceColumnTypes.size()) {
            throw new C3rIllegalArgumentException("Interactive schema generator given " + sourceHeaders.size() + " headers and " +
                    sourceColumnTypes.size() + " column data types.");
        }

        this.headers = sourceHeaders == null ? null : List.copyOf(sourceHeaders);
        this.sourceColumnTypes = sourceColumnTypes;
        this.sourceColumnCount = sourceColumnTypes.size();
        this.unsupportedTypeColumnCount = 0;
        this.targetJsonFile = targetJsonFile;
        this.consoleInput = (consoleInput == null)
                ? new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))
                : consoleInput;
        this.consoleOutput = (consoleOutput == null) ? new PrintStream(System.out, true, StandardCharsets.UTF_8)
                : consoleOutput;
        this.allowCleartextColumns = clientSettings == null || clientSettings.isAllowCleartext();
        generatedColumnSchemas = new ArrayList<>();
        usedColumnHeaders = new HashSet<>();
    }

    /**
     * Whether the source file has headers.
     *
     * @return {@code true} if the source file has headers, else {@code false}.
     */
    private boolean hasHeaders() {
        return headers != null;
    }

    /**
     * Has the user create the schema and writes it to a file. Also does some validation on the created schema such as at least one output
     * column was specified.
     *
     * @throws C3rRuntimeException If an I/O error occurs opening or creating the file
     */
    public void run() {
        if (!allowCleartextColumns) {
            consoleOutput.println();
            consoleOutput.println("NOTE: Cleartext columns are not permitted for this collaboration");
            consoleOutput.println("      and will not be provided as an option in prompts.");
        }
        generateColumns();
        final List<ColumnSchema> flattenedColumnSchemas = generatedColumnSchemas.stream()
                .flatMap(List::stream)
                .collect(Collectors.toList());

        if (flattenedColumnSchemas.isEmpty()) {
            if (unsupportedTypeColumnCount >= sourceColumnCount) {
                consoleOutput.println("No source columns could be considered for output:");
                consoleOutput.println("  all columns were of an unsupported type and the");
                consoleOutput.println("  specified collaboration does not allow cleartext.");
            } else {
                consoleOutput.println("No target columns were specified.");
            }
            return;
        }

        final TableSchema schema;
        if (hasHeaders()) {
            schema = new MappedTableSchema(flattenedColumnSchemas);
        } else {
            schema = new PositionalTableSchema(generatedColumnSchemas);
        }

        try (BufferedWriter writer = Files.newBufferedWriter(Path.of(targetJsonFile), StandardCharsets.UTF_8)) {
            writer.write(GsonUtil.toJson(schema));
        } catch (IOException e) {
            throw new C3rRuntimeException("Could not write to target schema file.", e);
        }
        consoleOutput.println("Schema written to " + targetJsonFile + ".");
    }

    /**
     * The current source column index target columns are being generated from.
     *
     * @return The current positional zero-based source index.
     */
    private int getCurrentSourceColumnPosition() {
        return generatedColumnSchemas.size();
    }

    /**
     * The current source column's client data type (how the data is represented).
     *
     * @return The client data type for the current source column.
     */
    private ClientDataType getCurrentSourceColumnDataType() {
        return sourceColumnTypes.get(getCurrentSourceColumnPosition());
    }

    /**
     * Gets the next line of text from the user and converts it to lowercase.
     *
     * @return Normalized user input
     * @throws C3rRuntimeException If there's an unexpected end of user input
     */
    private String readNextLineLowercase() {
        try {
            final String nextLine = consoleInput.readLine();
            if (nextLine == null) {
                throw new C3rRuntimeException("Unexpected end of user input.");
            }
            return nextLine.toLowerCase();
        } catch (IOException e) {
            throw new C3rRuntimeException("Unexpected end of user input.", e);
        }
    }

    /**
     * Prompt the user for a non-negative integer value.
     *
     * @param baseUserPrompt User prompt, sans any default value or ending question mark
     * @param defaultValue   What is the default user response they can leverage by simply
     *                       pressing `return` with no entered text. {@code defaultValue == null}
     *                       implies there is no default value
     * @param maxValue       The maximum allowed value
     * @return The user chosen value via the interaction, or {@code null} if no acceptable user input was found
     */
    Integer promptNonNegativeInt(final String baseUserPrompt,
                                 final Integer defaultValue,
                                 final int maxValue) {
        final var promptSB = new StringBuilder(baseUserPrompt);
        if (defaultValue != null) {
            promptSB.append(" (default `").append(defaultValue).append("`)");
        }
        promptSB.append("? ");
        consoleOutput.print(promptSB);

        final int num;
        final String userInput = readNextLineLowercase();
        try {
            num = Integer.parseInt(userInput);
        } catch (NumberFormatException e) {
            if (userInput.isBlank()) {
                if (defaultValue == null) {
                    consoleOutput.println("Expected an integer >= 0, but found no input.");
                }
                return defaultValue;
            } else {
                consoleOutput.println("Expected an integer >= 0, but found `" + userInput + "`.");
                return null;
            }
        }
        if (num < 0) {
            consoleOutput.println("Expected an integer >= 0, but found " + num + ".");
            return null;
        } else if (num > maxValue) {
            consoleOutput.println("Expected an integer >= 0 and < " + maxValue + ".");
            return null;
        }
        return num;
    }

    /**
     * Ask a user the {@code questionPrompt}, followed by a comma and [y]es or [n]o, and parse their response.
     *
     * @param questionPrompt What to print before `, [y]es or [n]o?`
     * @param defaultAnswer  A default answer for this prompt, or {@code null} if there is none.
     * @return {@code true} if `yes`, {@code false} if `no`, {@code null} otherwise.
     */
    Boolean promptYesOrNo(final String questionPrompt, final Boolean defaultAnswer) {
        final var promptSB = new StringBuilder(questionPrompt).append(", [y]es or [n]o");
        if (defaultAnswer != null) {
            if (defaultAnswer) {
                promptSB.append(" (default `yes`)");
            } else {
                promptSB.append(" (default `no`)");
            }
        }
        promptSB.append("? ");
        consoleOutput.print(promptSB);
        final String userInput = readNextLineLowercase();

        final Boolean answer;

        if (userInput.isBlank()) {
            if (defaultAnswer != null) {
                answer = defaultAnswer;
            } else {
                consoleOutput.println("Expected [y]es or [n]o, but found no input.");
                answer = null;
            }
        } else if ("yes".startsWith(userInput)) {
            answer = true;
        } else if ("no".startsWith(userInput)) {
            answer = false;
        } else {
            consoleOutput.println("Expected [y]es or [n]o, but got `" + userInput + "`.");
            answer = null;
        }
        return answer;
    }

    /**
     * Attempt to read a ColumnType.
     *
     * @return The ColumnType if successful, or {@code null} if the input was invalid
     */
    ColumnType promptColumnType() {
        final ColumnType type;
        if (allowCleartextColumns) {
            consoleOutput.print("Target column type: [c]leartext, [f]ingerprint, or [s]ealed? ");
        } else {
            consoleOutput.print("Target column type: [f]ingerprint, or [s]ealed? ");
        }
        final String userInput = readNextLineLowercase();
        if (userInput.isBlank()) {
            consoleOutput.println("Expected a column type, but found no input.");
            type = null;
        } else if (allowCleartextColumns && "cleartext".startsWith(userInput)) {
            type = ColumnType.CLEARTEXT;
        } else if ("fingerprint".startsWith(userInput)) {
            type = ColumnType.FINGERPRINT;
        } else if ("sealed".startsWith(userInput)) {
            type = ColumnType.SEALED;
        } else {
            consoleOutput.println("Expected a valid column type, but got `" + userInput + "`.");
            type = null;
        }
        return type;
    }

    /**
     * Repeat an action until it is non-{@code null}, e.g. for repeating requests for valid input.
     *
     * @param supplier Function that supplies the (eventually) non-null value.
     * @param <T>      The type of value to be returned by the supplier.
     * @return The non-{@code null} value eventually returned by the supplier.
     */
    static <T> T repeatUntilNotNull(final Supplier<T> supplier) {
        T result = null;
        while (result == null) {
            result = supplier.get();
        }
        return result;
    }

    /**
     * Suggest a suffix for the output column name based on the transform between input and output data selected.
     *
     * @param columnType The data transform type that will be used (see {@link ColumnType})
     * @return The selected suffix for the column name
     */
    String promptTargetHeaderSuffix(@NonNull final ColumnType columnType) {
        final String suggestedSuffix;
        switch (columnType) {
            case SEALED:
                suggestedSuffix = ColumnHeader.DEFAULT_SEALED_SUFFIX;
                break;
            case FINGERPRINT:
                suggestedSuffix = ColumnHeader.DEFAULT_FINGERPRINT_SUFFIX;
                break;
            default:
                // no suffix for cleartext columns
                suggestedSuffix = null;
                break;
        }
        final String suffix;
        if (suggestedSuffix != null) {
            final String prompt = "Add suffix `"
                    + suggestedSuffix + "` to header to indicate how it was encrypted";

            final boolean addSuffix = repeatUntilNotNull(() ->
                    promptYesOrNo(prompt, true));
            suffix = addSuffix ? suggestedSuffix : null;
        } else {
            suffix = null;
        }
        return suffix;
    }

    /**
     * Ask the user what they would like the column name in the output file to be. The default is the same as the input name. This is not
     * yet suggesting a suffix be added based off of encryption type.
     *
     * @param sourceHeader Input column name
     * @return Output column name
     * @throws C3rRuntimeException If there's an unexpected end of user input
     */
    private ColumnHeader promptTargetHeaderPreSuffix(final ColumnHeader sourceHeader) {
        final String input;
        final ColumnHeader targetHeader;
        if (sourceHeader != null) {
            consoleOutput.print("Target column header name (default `" + sourceHeader + "`)? ");
        } else {
            consoleOutput.print("Target column header name? ");
        }
        try {
            // We intentionally do not use readNextLineLowercase() here so that we can check if the
            // string was normalized and report it to the user for their awareness (see below).
            input = consoleInput.readLine();
            if (input != null && input.isBlank() && sourceHeader != null) {
                consoleOutput.println("Using default name `" + sourceHeader + "`.");
                targetHeader = sourceHeader;
            } else {
                targetHeader = new ColumnHeader(input);
            }
        } catch (C3rIllegalArgumentException e) {
            consoleOutput.println("Expected a valid header name, but found a problem: " + e.getMessage());
            return null;
        } catch (IOException e) {
            throw new C3rRuntimeException("Unexpected end of user input.", e);
        }

        if (!targetHeader.toString().equals(input) && targetHeader != sourceHeader) {
            consoleOutput.println("Target header was normalized to `" + targetHeader + "`.");
        }
        return targetHeader;
    }

    /**
     * Walks the user through the entire process of choosing an output column name, from the base name in
     * {@link #promptTargetHeaderPreSuffix} to the suffix in {@link #promptTargetHeaderSuffix}.
     *
     * @param sourceHeader Name of the input column
     * @param type         Type of cryptographic transform being done
     * @return Complete name for target column
     */
    private ColumnHeader promptTargetHeaderAndSuffix(
            final ColumnHeader sourceHeader,
            @NonNull final ColumnType type) {
        // Ask the user for a header name
        final ColumnHeader targetHeader = promptTargetHeaderPreSuffix(sourceHeader);
        if (targetHeader == null) {
            return null;
        }
        // Check if the user wants a type-based suffix, if applicable.
        final String suffix = promptTargetHeaderSuffix(type);
        if (suffix != null) {
            try {
                return new ColumnHeader(targetHeader + suffix);
            } catch (C3rIllegalArgumentException e) {
                consoleOutput.println("Unable to add header suffix: " + e.getMessage());
                return null;
            }
        } else {
            return targetHeader;
        }
    }

    /**
     * Gets the desired output header and verifies it does not match a name already specified.
     *
     * @param sourceHeader Name of input column
     * @param type         Encryption transform selected
     * @return Name of the output column
     */
    ColumnHeader promptTargetHeader(final ColumnHeader sourceHeader,
                                    @NonNull final ColumnType type) {
        final ColumnHeader targetHeader = promptTargetHeaderAndSuffix(sourceHeader, type);
        if (usedColumnHeaders.contains(targetHeader)) {
            consoleOutput.println("Expected a unique target header, but `" + targetHeader + "` has already been used in this schema.");
            return null;
        } else {
            usedColumnHeaders.add(targetHeader);
        }

        return targetHeader;
    }


    /**
     * If the user chose {@link ColumnType#SEALED} as the transform type, ask what kind of data padding should be used, if any.
     *
     * @param targetHeader Output column name
     * @param defaultType  Default type of padding to use if the user doesn't specify an option
     * @return Type of padding to use for output column
     */
    PadType promptPadType(@NonNull final ColumnHeader targetHeader, final PadType defaultType) {
        final PadType type;
        consoleOutput.print("`" + targetHeader + "` padding type: [n]one, [f]ixed, or [m]ax");
        if (defaultType != null) {
            consoleOutput.print(" (default `" + defaultType.toString().toLowerCase() + "`)");
        }
        consoleOutput.print("? ");
        final String userInput = readNextLineLowercase();
        if (userInput.isBlank()) {
            if (defaultType == null) {
                consoleOutput.println("Expected a padding type, but found no input.");
            }
            type = defaultType;
        } else if ("none".startsWith(userInput)) {
            type = PadType.NONE;
        } else if ("fixed".startsWith(userInput)) {
            type = PadType.FIXED;
        } else if ("max".startsWith(userInput)) {
            type = PadType.MAX;
        } else {
            consoleOutput.println("Expected a valid padding type, but got `" + userInput + "`.");
            type = null;
        }
        return type;
    }

    /**
     * Get the type of padding to be used (see {@link PadType}) and length if the user chose {@link ColumnType#SEALED}.
     *
     * @param targetHeader Name of the output column
     * @return Pad type and length
     * @see PadType
     * @see Pad
     */
    Pad promptPad(@NonNull final ColumnHeader targetHeader) {
        final PadType padType = repeatUntilNotNull(() ->
                promptPadType(targetHeader, PadType.MAX)
        );

        if (padType == PadType.NONE) {
            return Pad.DEFAULT;
        }

        final String basePrompt;
        final Integer defaultLength;
        if (padType == PadType.FIXED) {
            defaultLength = null;
            basePrompt = "Byte-length to pad cleartext to in `" + targetHeader + "`";
        } else {
            // padType == PadType.MAX
            defaultLength = 0;
            consoleOutput.println("All values in `" + targetHeader + "` will be padded to the byte-length of the");
            consoleOutput.println("longest value plus a specified number of additional padding bytes.");
            basePrompt = "How many additional padding bytes should be used";
        }

        final int length = repeatUntilNotNull(() ->
                promptNonNegativeInt(basePrompt, defaultLength, PadUtil.MAX_PAD_BYTES)
        );
        return Pad.builder().type(padType).length(length).build();

    }

    /**
     * Prompt for all column info to generate a target column.
     *
     * @param sourceHeader             Source column target is derived from
     * @param currentTargetColumnCount This is column `N` of {@code totalTargetColumnCount}
     *                                 being generated from {@code sourceHeader}
     * @param totalTargetColumnCount   Total number of columns being generated from {@code sourceHeader}.
     * @return The user-provided column specification.
     */
    ColumnSchema promptColumnInfo(final ColumnHeader sourceHeader,
                                  final int currentTargetColumnCount,
                                  final int totalTargetColumnCount) {
        consoleOutput.println();
        consoleOutput.print("Gathering information for target column ");
        if (totalTargetColumnCount > 1) {
            consoleOutput.print(currentTargetColumnCount + " of " + totalTargetColumnCount + " ");
        }
        final String columnRef = SchemaGeneratorUtils.columnReference(sourceHeader, getCurrentSourceColumnPosition());
        consoleOutput.println("from source " + columnRef + ".");

        final ClientDataType dataType = getCurrentSourceColumnDataType();
        final ColumnType columnType;
        if (dataType == ClientDataType.UNKNOWN) {
            consoleOutput.println("Cryptographic computing is not supported for this column's data type.");
            consoleOutput.println("This column's data will be cleartext.");
            columnType = ColumnType.CLEARTEXT;
        } else {
            columnType = repeatUntilNotNull(this::promptColumnType);
        }

        final ColumnHeader targetHeader = repeatUntilNotNull(() -> promptTargetHeader(sourceHeader, columnType));

        ColumnSchema.ColumnSchemaBuilder columnBuilder = ColumnSchema.builder()
                .sourceHeader(sourceHeader)
                .targetHeader(targetHeader)
                .type(columnType);
        if (columnType == ColumnType.SEALED) {
            final Pad pad = repeatUntilNotNull(() -> promptPad(targetHeader));
            columnBuilder = columnBuilder.pad(pad);
        }
        return columnBuilder.build();
    }

    /**
     * Asks how many times this column will be mapped to output data. A one-to-one mapping is not assumed because multiple transform types
     * may be used.
     *
     * @param sourceHeader Name of the input column
     */
    void generateTargetColumns(final ColumnHeader sourceHeader) {
        final String columnReference = SchemaGeneratorUtils.columnReference(sourceHeader, getCurrentSourceColumnPosition());

        final int defaultTargetColumnCount = 1;
        consoleOutput.println("\nExamining source " + columnReference + ".");
        final boolean isSupportedType = getCurrentSourceColumnDataType() != ClientDataType.UNKNOWN;

        final int targetColumnCount;
        if (isSupportedType || allowCleartextColumns) {
            if (!isSupportedType) {
                // Warn that this column can only appear as cleartext
                consoleOutput.println(SchemaGeneratorUtils.unsupportedTypeWarning(sourceHeader, getCurrentSourceColumnPosition()));
            }
            targetColumnCount = repeatUntilNotNull(() ->
                    promptNonNegativeInt(
                            "Number of target columns from source " + columnReference,
                            defaultTargetColumnCount,
                            Limits.ENCRYPTED_OUTPUT_COLUMN_COUNT_MAX));
        } else {
            // This column cannot even appear as cleartext because of collaboration settings,
            // so warn that it will be skipped
            consoleOutput.println(SchemaGeneratorUtils.unsupportedTypeSkippingColumnWarning(
                    sourceHeader,
                    getCurrentSourceColumnPosition()));
            unsupportedTypeColumnCount++;
            targetColumnCount = 0;
        }

        // schemas derived from the current source column are stored in this array
        final var targetSchemasFromSourceColumn = new ArrayList<ColumnSchema>(targetColumnCount);
        // 1-based indices since `i` is only used really to count and print user messages if `targetColumnCount > 1`
        // and `1 of N` looks better than `0 of N-1` in printed messages.
        for (int i = 1; i <= targetColumnCount; i++) {
            targetSchemasFromSourceColumn.add(promptColumnInfo(sourceHeader, i, targetColumnCount));
        }
        generatedColumnSchemas.add(targetSchemasFromSourceColumn);
    }

    /**
     * Ask the user how to map each input column to output data until all columns have been processed.
     */
    private void generateColumns() {
        if (headers != null) {
            for (var header : headers) {
                generateTargetColumns(header);
            }
        } else {
            for (int i = 0; i < sourceColumnCount; i++) {
                generateTargetColumns(null);
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -



c3r-cli/src/main/java/com/amazonaws/c3r/io/schema/InteractiveSchemaGenerator.java [86:652]:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    private final String targetJsonFile;

    /**
     * Console input from user.
     */
    private final BufferedReader consoleInput;

    /**
     * Console output stream.
     */
    private final PrintStream consoleOutput;

    /**
     * Whether cleartext columns possible for this schema.
     */
    private final boolean allowCleartextColumns;

    /**
     * Sets up the schema generator to run in interactive mode. Makes I/O connections to console, processes header information and
     * initializes preprocessing state.
     *
     * @param sourceHeaders     Column names in data file if they exist, otherwise {@code null}
     * @param sourceColumnTypes The column types in the file in the order they appear
     * @param targetJsonFile    Where schema should be written
     * @param consoleInput      Connection to input stream (i.e., input from user)
     * @param consoleOutput     Connection to output stream (i.e., output for user)
     * @param clientSettings    Collaboration's client settings if provided, else {@code null}
     * @throws C3rIllegalArgumentException If input sizes are inconsistent
     */
    @Builder
    @SuppressFBWarnings({"EI_EXPOSE_REP", "EI_EXPOSE_REP2"})
    private InteractiveSchemaGenerator(final List<ColumnHeader> sourceHeaders,
                                       @NonNull final List<ClientDataType> sourceColumnTypes,
                                       @NonNull final String targetJsonFile,
                                       final BufferedReader consoleInput,
                                       final PrintStream consoleOutput,
                                       final ClientSettings clientSettings) {
        if (sourceHeaders != null && sourceHeaders.size() != sourceColumnTypes.size()) {
            throw new C3rIllegalArgumentException("Interactive schema generator given " + sourceHeaders.size() + " headers and " +
                    sourceColumnTypes.size() + " column data types.");
        }

        this.headers = sourceHeaders == null ? null : List.copyOf(sourceHeaders);
        this.sourceColumnTypes = sourceColumnTypes;
        this.sourceColumnCount = sourceColumnTypes.size();
        this.unsupportedTypeColumnCount = 0;
        this.targetJsonFile = targetJsonFile;
        this.consoleInput = (consoleInput == null)
                ? new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))
                : consoleInput;
        this.consoleOutput = (consoleOutput == null) ? new PrintStream(System.out, true, StandardCharsets.UTF_8)
                : consoleOutput;
        this.allowCleartextColumns = clientSettings == null || clientSettings.isAllowCleartext();
        generatedColumnSchemas = new ArrayList<>();
        usedColumnHeaders = new HashSet<>();
    }

    /**
     * Whether the source file has headers.
     *
     * @return {@code true} if the source file has headers, else {@code false}.
     */
    private boolean hasHeaders() {
        return headers != null;
    }

    /**
     * Has the user create the schema and writes it to a file. Also does some validation on the created schema such as at least one output
     * column was specified.
     *
     * @throws C3rRuntimeException If an I/O error occurs opening or creating the file
     */
    public void run() {
        if (!allowCleartextColumns) {
            consoleOutput.println();
            consoleOutput.println("NOTE: Cleartext columns are not permitted for this collaboration");
            consoleOutput.println("      and will not be provided as an option in prompts.");
        }
        generateColumns();
        final List<ColumnSchema> flattenedColumnSchemas = generatedColumnSchemas.stream()
                .flatMap(List::stream)
                .collect(Collectors.toList());

        if (flattenedColumnSchemas.isEmpty()) {
            if (unsupportedTypeColumnCount >= sourceColumnCount) {
                consoleOutput.println("No source columns could be considered for output:");
                consoleOutput.println("  all columns were of an unsupported type and the");
                consoleOutput.println("  specified collaboration does not allow cleartext.");
            } else {
                consoleOutput.println("No target columns were specified.");
            }
            return;
        }

        final TableSchema schema;
        if (hasHeaders()) {
            schema = new MappedTableSchema(flattenedColumnSchemas);
        } else {
            schema = new PositionalTableSchema(generatedColumnSchemas);
        }

        try (BufferedWriter writer = Files.newBufferedWriter(Path.of(targetJsonFile), StandardCharsets.UTF_8)) {
            writer.write(GsonUtil.toJson(schema));
        } catch (IOException e) {
            throw new C3rRuntimeException("Could not write to target schema file.", e);
        }
        consoleOutput.println("Schema written to " + targetJsonFile + ".");
    }

    /**
     * The current source column index target columns are being generated from.
     *
     * @return The current positional zero-based source index.
     */
    private int getCurrentSourceColumnPosition() {
        return generatedColumnSchemas.size();
    }

    /**
     * The current source column's client data type (how the data is represented).
     *
     * @return The client data type for the current source column.
     */
    private ClientDataType getCurrentSourceColumnDataType() {
        return sourceColumnTypes.get(getCurrentSourceColumnPosition());
    }

    /**
     * Gets the next line of text from the user and converts it to lowercase.
     *
     * @return Normalized user input
     * @throws C3rRuntimeException If there's an unexpected end of user input
     */
    private String readNextLineLowercase() {
        try {
            final String nextLine = consoleInput.readLine();
            if (nextLine == null) {
                throw new C3rRuntimeException("Unexpected end of user input.");
            }
            return nextLine.toLowerCase();
        } catch (IOException e) {
            throw new C3rRuntimeException("Unexpected end of user input.", e);
        }
    }


    /**
     * Prompt the user for a non-negative integer value.
     *
     * @param baseUserPrompt User prompt, sans any default value or ending question mark
     * @param defaultValue   What is the default user response they can leverage by simply
     *                       pressing `return` with no entered text. {@code defaultValue == null}
     *                       implies there is no default value
     * @param maxValue       The maximum allowed value
     * @return The user chosen value via the interaction, or {@code null} if no acceptable user input was found
     */
    Integer promptNonNegativeInt(final String baseUserPrompt,
                                 final Integer defaultValue,
                                 final int maxValue) {
        final var promptSB = new StringBuilder(baseUserPrompt);
        if (defaultValue != null) {
            promptSB.append(" (default `").append(defaultValue).append("`)");
        }
        promptSB.append("? ");
        consoleOutput.print(promptSB);

        final int num;
        final String userInput = readNextLineLowercase();
        try {
            num = Integer.parseInt(userInput);
        } catch (NumberFormatException e) {
            if (userInput.isBlank()) {
                if (defaultValue == null) {
                    consoleOutput.println("Expected an integer >= 0, but found no input.");
                }
                return defaultValue;
            } else {
                consoleOutput.println("Expected an integer >= 0, but found `" + userInput + "`.");
                return null;
            }
        }
        if (num < 0) {
            consoleOutput.println("Expected an integer >= 0, but found " + num + ".");
            return null;
        } else if (num > maxValue) {
            consoleOutput.println("Expected an integer >= 0 and < " + maxValue + ".");
            return null;
        }
        return num;
    }

    /**
     * Ask a user the {@code questionPrompt}, followed by a comma and [y]es or [n]o, and parse their response.
     *
     * @param questionPrompt What to print before `, [y]es or [n]o?`
     * @param defaultAnswer  A default answer for this prompt, or {@code null} if there is none.
     * @return {@code true} if `yes`, {@code false} if `no`, {@code null} otherwise.
     */
    Boolean promptYesOrNo(final String questionPrompt, final Boolean defaultAnswer) {
        final var promptSB = new StringBuilder(questionPrompt).append(", [y]es or [n]o");
        if (defaultAnswer != null) {
            if (defaultAnswer) {
                promptSB.append(" (default `yes`)");
            } else {
                promptSB.append(" (default `no`)");
            }
        }
        promptSB.append("? ");
        consoleOutput.print(promptSB);
        final String userInput = readNextLineLowercase();

        final Boolean answer;

        if (userInput.isBlank()) {
            if (defaultAnswer != null) {
                answer = defaultAnswer;
            } else {
                consoleOutput.println("Expected [y]es or [n]o, but found no input.");
                answer = null;
            }
        } else if ("yes".startsWith(userInput)) {
            answer = true;
        } else if ("no".startsWith(userInput)) {
            answer = false;
        } else {
            consoleOutput.println("Expected [y]es or [n]o, but got `" + userInput + "`.");
            answer = null;
        }
        return answer;
    }

    /**
     * Attempt to read a ColumnType.
     *
     * @return The ColumnType if successful, or {@code null} if the input was invalid
     */
    ColumnType promptColumnType() {
        final ColumnType type;
        if (allowCleartextColumns) {
            consoleOutput.print("Target column type: [c]leartext, [f]ingerprint, or [s]ealed? ");
        } else {
            consoleOutput.print("Target column type: [f]ingerprint, or [s]ealed? ");
        }
        final String userInput = readNextLineLowercase();
        if (userInput.isBlank()) {
            consoleOutput.println("Expected a column type, but found no input.");
            type = null;
        } else if (allowCleartextColumns && "cleartext".startsWith(userInput)) {
            type = ColumnType.CLEARTEXT;
        } else if ("fingerprint".startsWith(userInput)) {
            type = ColumnType.FINGERPRINT;
        } else if ("sealed".startsWith(userInput)) {
            type = ColumnType.SEALED;
        } else {
            consoleOutput.println("Expected a valid column type, but got `" + userInput + "`.");
            type = null;
        }
        return type;
    }

    /**
     * Repeat an action until it is non-{@code null}, e.g. for repeating requests for valid input.
     *
     * @param supplier Function that supplies the (eventually) non-null value.
     * @param <T> The type of value to be returned by the supplier.
     * @return The non-{@code null} value eventually returned by the supplier.
     */
    static <T> T repeatUntilNotNull(final Supplier<T> supplier) {
        T result = null;
        while (result == null) {
            result = supplier.get();
        }
        return result;
    }

    /**
     * Suggest a suffix for the output column name based on the transform between input and output data selected.
     *
     * @param columnType The data transform type that will be used (see {@link ColumnType})
     * @return The selected suffix for the column name
     */
    String promptTargetHeaderSuffix(@NonNull final ColumnType columnType) {
        final String suggestedSuffix;
        switch (columnType) {
            case SEALED:
                suggestedSuffix = ColumnHeader.DEFAULT_SEALED_SUFFIX;
                break;
            case FINGERPRINT:
                suggestedSuffix = ColumnHeader.DEFAULT_FINGERPRINT_SUFFIX;
                break;
            default:
                // no suffix for cleartext columns
                suggestedSuffix = null;
                break;
        }
        final String suffix;
        if (suggestedSuffix != null) {
            final String prompt = "Add suffix `"
                    + suggestedSuffix + "` to header to indicate how it was encrypted";

            final boolean addSuffix = repeatUntilNotNull(() ->
                    promptYesOrNo(prompt, true));
            suffix = addSuffix ? suggestedSuffix : null;
        } else {
            suffix = null;
        }
        return suffix;
    }

    /**
     * Ask the user what they would like the column name in the output file to be. The default is the same as the input name. This is not
     * yet suggesting a suffix be added based off of encryption type.
     *
     * @param sourceHeader Input column name
     * @return Output column name
     * @throws C3rRuntimeException If there's an unexpected end of user input
     */
    private ColumnHeader promptTargetHeaderPreSuffix(final ColumnHeader sourceHeader) {
        final String input;
        final ColumnHeader targetHeader;
        if (sourceHeader != null) {
            consoleOutput.print("Target column header name (default `" + sourceHeader + "`)? ");
        } else {
            consoleOutput.print("Target column header name? ");
        }
        try {
            // We intentionally do not use readNextLineLowercase() here so that we can check if the
            // string was normalized and report it to the user for their awareness (see below).
            input = consoleInput.readLine();
            if (input != null && input.isBlank() && sourceHeader != null) {
                consoleOutput.println("Using default name `" + sourceHeader + "`.");
                targetHeader = sourceHeader;
            } else {
                targetHeader = new ColumnHeader(input);
            }
        } catch (C3rIllegalArgumentException e) {
            consoleOutput.println("Expected a valid header name, but found a problem: " + e.getMessage());
            return null;
        } catch (IOException e) {
            throw new C3rRuntimeException("Unexpected end of user input.", e);
        }

        if (!targetHeader.toString().equals(input) && targetHeader != sourceHeader) {
            consoleOutput.println("Target header was normalized to `" + targetHeader + "`.");
        }
        return targetHeader;
    }

    /**
     * Walks the user through the entire process of choosing an output column name, from the base name in
     * {@link #promptTargetHeaderPreSuffix} to the suffix in {@link #promptTargetHeaderSuffix}.
     *
     * @param sourceHeader Name of the input column
     * @param type         Type of cryptographic transform being done
     * @return Complete name for target column
     */
    private ColumnHeader promptTargetHeaderAndSuffix(
            final ColumnHeader sourceHeader,
            @NonNull final ColumnType type) {
        // Ask the user for a header name
        final ColumnHeader targetHeader = promptTargetHeaderPreSuffix(sourceHeader);
        if (targetHeader == null) {
            return null;
        }
        // Check if the user wants a type-based suffix, if applicable.
        final String suffix = promptTargetHeaderSuffix(type);
        if (suffix != null) {
            try {
                return new ColumnHeader(targetHeader + suffix);
            } catch (C3rIllegalArgumentException e) {
                consoleOutput.println("Unable to add header suffix: " + e.getMessage());
                return null;
            }
        } else {
            return targetHeader;
        }
    }

    /**
     * Gets the desired output header and verifies it does not match a name already specified.
     *
     * @param sourceHeader Name of input column
     * @param type         Encryption transform selected
     * @return Name of the output column
     */
    ColumnHeader promptTargetHeader(final ColumnHeader sourceHeader,
                                    @NonNull final ColumnType type) {
        final ColumnHeader targetHeader = promptTargetHeaderAndSuffix(sourceHeader, type);
        if (usedColumnHeaders.contains(targetHeader)) {
            consoleOutput.println("Expected a unique target header, but `" + targetHeader + "` has already been used in this schema.");
            return null;
        } else {
            usedColumnHeaders.add(targetHeader);
        }

        return targetHeader;
    }


    /**
     * If the user chose {@link ColumnType#SEALED} as the transform type, ask what kind of data padding should be used, if any.
     *
     * @param targetHeader Output column name
     * @param defaultType  Default type of padding to use if the user doesn't specify an option
     * @return Type of padding to use for output column
     */
    PadType promptPadType(@NonNull final ColumnHeader targetHeader, final PadType defaultType) {
        final PadType type;
        consoleOutput.print("`" + targetHeader + "` padding type: [n]one, [f]ixed, or [m]ax");
        if (defaultType != null) {
            consoleOutput.print(" (default `" + defaultType.toString().toLowerCase() + "`)");
        }
        consoleOutput.print("? ");
        final String userInput = readNextLineLowercase();
        if (userInput.isBlank()) {
            if (defaultType == null) {
                consoleOutput.println("Expected a padding type, but found no input.");
            }
            type = defaultType;
        } else if ("none".startsWith(userInput)) {
            type = PadType.NONE;
        } else if ("fixed".startsWith(userInput)) {
            type = PadType.FIXED;
        } else if ("max".startsWith(userInput)) {
            type = PadType.MAX;
        } else {
            consoleOutput.println("Expected a valid padding type, but got `" + userInput + "`.");
            type = null;
        }
        return type;
    }

    /**
     * Get the type of padding to be used (see {@link PadType}) and length if the user chose {@link ColumnType#SEALED}.
     *
     * @param targetHeader Name of the output column
     * @return Pad type and length
     * @see PadType
     * @see Pad
     */
    Pad promptPad(@NonNull final ColumnHeader targetHeader) {
        final PadType padType = repeatUntilNotNull(() ->
                promptPadType(targetHeader, PadType.MAX)
        );

        if (padType == PadType.NONE) {
            return Pad.DEFAULT;
        }

        final String basePrompt;
        final Integer defaultLength;
        if (padType == PadType.FIXED) {
            defaultLength = null;
            basePrompt = "Byte-length to pad cleartext to in `" + targetHeader + "`";
        } else {
            // padType == PadType.MAX
            defaultLength = 0;
            consoleOutput.println("All values in `" + targetHeader + "` will be padded to the byte-length of the");
            consoleOutput.println("longest value plus a specified number of additional padding bytes.");
            basePrompt = "How many additional padding bytes should be used";
        }

        final int length = repeatUntilNotNull(() ->
                promptNonNegativeInt(basePrompt, defaultLength, PadUtil.MAX_PAD_BYTES)
        );
        return Pad.builder().type(padType).length(length).build();

    }

    /**
     * Prompt for all column info to generate a target column.
     *
     * @param sourceHeader             Source column target is derived from
     * @param currentTargetColumnCount This is column `N` of {@code totalTargetColumnCount}
     *                                 being generated from {@code sourceHeader}
     * @param totalTargetColumnCount   Total number of columns being generated from {@code sourceHeader}.
     * @return The user-provided column specification.
     */
    ColumnSchema promptColumnInfo(final ColumnHeader sourceHeader,
                                  final int currentTargetColumnCount,
                                  final int totalTargetColumnCount) {
        consoleOutput.println();
        consoleOutput.print("Gathering information for target column ");
        if (totalTargetColumnCount > 1) {
            consoleOutput.print(currentTargetColumnCount + " of " + totalTargetColumnCount + " ");
        }
        final String columnRef = SchemaGeneratorUtils.columnReference(sourceHeader, getCurrentSourceColumnPosition());
        consoleOutput.println("from source " + columnRef + ".");

        final ClientDataType dataType = getCurrentSourceColumnDataType();
        final ColumnType columnType;
        if (dataType == ClientDataType.UNKNOWN) {
            consoleOutput.println("Cryptographic computing is not supported for this column's data type.");
            consoleOutput.println("This column's data will be cleartext.");
            columnType = ColumnType.CLEARTEXT;
        } else {
            columnType = repeatUntilNotNull(this::promptColumnType);
        }

        final ColumnHeader targetHeader = repeatUntilNotNull(() -> promptTargetHeader(sourceHeader, columnType));

        ColumnSchema.ColumnSchemaBuilder columnBuilder = ColumnSchema.builder()
                .sourceHeader(sourceHeader)
                .targetHeader(targetHeader)
                .type(columnType);
        if (columnType == ColumnType.SEALED) {
            final Pad pad = repeatUntilNotNull(() -> promptPad(targetHeader));
            columnBuilder = columnBuilder.pad(pad);
        }
        return columnBuilder.build();
    }

    /**
     * Asks how many times this column will be mapped to output data. A one-to-one mapping is not assumed because multiple transform types
     * may be used.
     *
     * @param sourceHeader Name of the input column
     */
    void generateTargetColumns(final ColumnHeader sourceHeader) {
        final String columnReference = SchemaGeneratorUtils.columnReference(sourceHeader, getCurrentSourceColumnPosition());

        final int defaultTargetColumnCount = 1;
        consoleOutput.println("\nExamining source " + columnReference + ".");
        final boolean isSupportedType = getCurrentSourceColumnDataType() != ClientDataType.UNKNOWN;

        final int targetColumnCount;
        if (isSupportedType || allowCleartextColumns) {
            if (!isSupportedType) {
                // Warn that this column can only appear as cleartext
                consoleOutput.println(SchemaGeneratorUtils.unsupportedTypeWarning(sourceHeader, getCurrentSourceColumnPosition()));
            }
            targetColumnCount = repeatUntilNotNull(() ->
                    promptNonNegativeInt(
                            "Number of target columns from source " + columnReference,
                            defaultTargetColumnCount,
                            Limits.ENCRYPTED_OUTPUT_COLUMN_COUNT_MAX));
        } else {
            // This column cannot even appear as cleartext because of collaboration settings,
            // so warn that it will be skipped
            consoleOutput.println(SchemaGeneratorUtils.unsupportedTypeSkippingColumnWarning(
                    sourceHeader,
                    getCurrentSourceColumnPosition()));
            unsupportedTypeColumnCount++;
            targetColumnCount = 0;
        }

        // schemas derived from the current source column are stored in this array
        final var targetSchemasFromSourceColumn = new ArrayList<ColumnSchema>(targetColumnCount);
        // 1-based indices since `i` is only used really to count and print user messages if `targetColumnCount > 1`
        // and `1 of N` looks better than `0 of N-1` in printed messages.
        for (int i = 1; i <= targetColumnCount; i++) {
            targetSchemasFromSourceColumn.add(promptColumnInfo(sourceHeader, i, targetColumnCount));
        }
        generatedColumnSchemas.add(targetSchemasFromSourceColumn);
    }

    /**
     * Ask the user how to map each input column to output data until all columns have been processed.
     */
    private void generateColumns() {
        if (headers != null) {
            for (var header : headers) {
                generateTargetColumns(header);
            }
        } else {
            for (int i = 0; i < sourceColumnCount; i++) {
                generateTargetColumns(null);
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -



