/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.commons.rng.examples.stress;

import org.apache.commons.rng.UniformRandomProvider;
import org.apache.commons.rng.core.source64.RandomLongSource;
import org.apache.commons.rng.simple.RandomSource;

import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Formatter;
import java.util.List;
import java.util.concurrent.Callable;

/**
 * Specification for the "output" command.
 *
 * <p>This command creates a named random generator and outputs data in a specified format.</p>
 */
@Command(name = "output",
         description = {"Output data from a named random data generator."})
class OutputCommand implements Callable<Void> {
    /** The new line characters. */
    private static final String NEW_LINE = System.lineSeparator();
    /** Character '['. */
    private static final char LEFT_SQUARE_BRACKET = '[';
    /** Character ']'. */
    private static final char RIGHT_SQUARE_BRACKET = ']';

    /** Lookup table for binary representation of bytes. */
    private static final String[] BIT_REP = {
        "0000", "0001", "0010", "0011",
        "0100", "0101", "0110", "0111",
        "1000", "1001", "1010", "1011",
        "1100", "1101", "1110", "1111",
    };

    /** The standard options. */
    @Mixin
    private StandardOptions reusableOptions;

    /** The random source. */
    @Parameters(index = "0",
                description = "The random source.")
    private RandomSource randomSource;

    /** The executable arguments. */
    @Parameters(index = "1..*",
                description = "The arguments to pass to the constructor.",
                paramLabel = "<argument>")
    private List<String> arguments = new ArrayList<>();

    /** The file output prefix. */
    @Option(names = {"-o", "--out"},
            description = "The output file (default: stdout).")
    private File fileOutput;

    /** The output format. */
    @Option(names = {"-f", "--format"},
            description = {"Output format (default: ${DEFAULT-VALUE}).",
                           "Valid values: ${COMPLETION-CANDIDATES}."})
    private OutputCommand.OutputFormat outputFormat = OutputFormat.DIEHARDER;

    /** The random seed. */
    @Option(names = {"-s", "--seed"},
            description = {"The 64-bit number random seed (default: auto)."})
    private Long seed;

    /** The random seed as a byte[]. */
    @Option(names = {"-x", "--hex-seed"},
            description = {"The hex-encoded random seed.",
                           "Seed conversion for multi-byte primitives use little-endian format.",
                           "Over-rides the --seed parameter."})
    private String byteSeed;

    /** The count of numbers to output. */
    @Option(names = {"-n", "--count"},
            description = {"The count of numbers to output.",
                           "Use negative for an unlimited stream."})
    private long count = 10;

    /** The size of the byte buffer for the binary data. */
    @Option(names = {"--buffer-size"},
            description = {"Byte-buffer size for binary data (default: ${DEFAULT-VALUE}).",
                           "When outputing binary data the count parameter controls the " +
                           "number of buffers written."})
    private int bufferSize = 8192;

    /** The output byte order of the binary data. */
    @Option(names = {"-b", "--byte-order"},
            description = {"Byte-order of the output data (default: ${DEFAULT-VALUE}).",
                           "Uses the Java default of big-endian. This may not match the platform byte-order.",
                           "Valid values: BIG_ENDIAN, LITTLE_ENDIAN."})
    private ByteOrder byteOrder = ByteOrder.BIG_ENDIAN;

    /** The output byte order of the binary data. */
    @Option(names = {"-r", "--reverse-bits"},
            description = {"Reverse the bits in the data (default: ${DEFAULT-VALUE})."})
    private boolean reverseBits;

    /** Flag to use 64-bit long output. */
    @Option(names = {"--raw64"},
            description = {"Use 64-bit output (default is 32-bit).",
                           "This is ignored if not a native 64-bit generator.",
                           "Set to true sets the source64 mode to LONG."})
    private boolean raw64;

    /** Output mode for 64-bit long output. */
    @Option(names = {"--source64"},
            description = {"Output mode for 64-bit generators (default: ${DEFAULT-VALUE}).",
                           "This is ignored if not a native 64-bit generator.",
                           "In 32-bit mode the output uses a combination of upper and " +
                           "lower bits of the 64-bit value.",
                           "Valid values: ${COMPLETION-CANDIDATES}."})
    private Source64Mode source64 = RNGUtils.getSource64Default();

    /**
     * The output mode for existing files.
     */
    enum OutputFormat {
        /** Binary output. */
        BINARY,
        /** Use the Dieharder text format. */
        DIEHARDER,
        /** Output the bits in a text format. */
        BITS,
    }

    /**
     * Validates the command arguments, creates the generator and outputs numbers.
     */
    @Override
    public Void call() {
        LogUtils.setLogLevel(reusableOptions.logLevel);
        final Object objectSeed = createSeed();
        UniformRandomProvider rng = createRNG(objectSeed);

        // raw64 flag overrides the source64 mode
        if (raw64) {
            source64 = Source64Mode.LONG;
        }
        if (source64 == Source64Mode.LONG && !(rng instanceof RandomLongSource)) {
            throw new ApplicationException("Not a 64-bit RNG: " + rng);
        }

        // Upper or lower bits from 64-bit generators must be created first.
        // Note this does not test source64 != Source64Mode.LONG as the full long
        // output split into hi-lo or lo-hi is supported by the RngDataOutput.
        if (rng instanceof RandomLongSource &&
            (source64 == Source64Mode.HI || source64 == Source64Mode.LO || source64 == Source64Mode.INT)) {
            rng = RNGUtils.createIntProvider((UniformRandomProvider & RandomLongSource) rng, source64);
        }
        if (reverseBits) {
            rng = RNGUtils.createReverseBitsProvider(rng);
        }

        // -------
        // Note: Manipulation of the byte order for the platform is done during output
        // for the binary format. Otherwise do it in Java.
        // -------
        if (outputFormat != OutputFormat.BINARY) {
            rng = toOutputFormat(rng);
        }

        try (OutputStream out = createOutputStream()) {
            switch (outputFormat) {
            case BINARY:
                writeBinaryData(rng, out);
                break;
            case DIEHARDER:
                writeDieharder(rng, out);
                break;
            case BITS:
                writeBitData(rng, out);
                break;
            default:
                throw new ApplicationException("Unknown output format: " + outputFormat);
            }
        } catch (IOException ex) {
            throw new ApplicationException("IO error: " + ex.getMessage(), ex);
        }
        return null;
    }

    /**
     * Creates the seed.
     *
     * @return the seed
     */
    private Object createSeed() {
        if (byteSeed != null) {
            try {
                return Hex.decodeHex(byteSeed);
            } catch (IllegalArgumentException ex) {
                throw new ApplicationException("Invalid hex seed: " + ex.getMessage(), ex);
            }
        }
        if (seed != null) {
            return seed;
        }
        // Let the factory constructor create the native seed.
        return null;
    }

    /**
     * Creates the seed.
     *
     * @return the seed
     */
    private String createSeedString() {
        if (byteSeed != null) {
            return byteSeed;
        }
        if (seed != null) {
            return seed.toString();
        }
        return "auto";
    }

    /**
     * Creates the RNG.
     *
     * @param objectSeed Seed.
     * @return the uniform random provider
     * @throws ApplicationException If the RNG cannot be created
     */
    private UniformRandomProvider createRNG(Object objectSeed) {
        if (randomSource == null) {
            throw new ApplicationException("Random source is null");
        }
        final ArrayList<Object> data = new ArrayList<>();
        // Note: The list command outputs arguments as an array bracketed by [ and ]
        // Strip these for convenience.
        stripArrayFormatting(arguments);

        for (final String argument : arguments) {
            data.add(RNGUtils.parseArgument(argument));
        }
        try {
            return randomSource.create(objectSeed, data.toArray());
        } catch (IllegalStateException | IllegalArgumentException ex) {
            throw new ApplicationException("Failed to create RNG: " + randomSource + ". " + ex.getMessage(), ex);
        }
    }

    /**
     * Strip leading bracket from the first argument, trailing bracket from the last
     * argument, and any trailing commas from any argument.
     *
     * <p>This is used to remove the array formatting used by the list command.
     *
     * @param arguments the arguments
     */
    private static void stripArrayFormatting(List<String> arguments) {
        final int size = arguments.size();
        if (size > 1) {
            // These will not be empty as they were created from command-line args.
            final String first = arguments.get(0);
            if (first.charAt(0) == LEFT_SQUARE_BRACKET) {
                arguments.set(0, first.substring(1));
            }
            final String last = arguments.get(size - 1);
            if (last.charAt(last.length() - 1) == RIGHT_SQUARE_BRACKET) {
                arguments.set(size - 1, last.substring(0, last.length() - 1));
            }
        }
        for (int i = 0; i < size; i++) {
            final String argument = arguments.get(i);
            if (argument.endsWith(",")) {
                arguments.set(i, argument.substring(0, argument.length() - 1));
            }
        }
    }

    /**
     * Convert the native RNG to the requested output format. This will convert a 64-bit
     * generator to a 32-bit generator unless the 64-bit mode is active. It then optionally
     * reverses the byte order of the output.
     *
     * @param rng The random generator.
     * @return the uniform random provider
     */
    private UniformRandomProvider toOutputFormat(UniformRandomProvider rng) {
        UniformRandomProvider convertedRng = rng;
        if (rng instanceof RandomLongSource && source64 != Source64Mode.LONG) {
            // Convert to 32-bit generator
            convertedRng = RNGUtils.createIntProvider((UniformRandomProvider & RandomLongSource) rng, source64);
        }
        if (byteOrder == ByteOrder.LITTLE_ENDIAN) {
            convertedRng = RNGUtils.createReverseBytesProvider(convertedRng);
        }
        return convertedRng;
    }

    /**
     * Creates the output stream. This will not be buffered.
     *
     * @return the output stream
     */
    private OutputStream createOutputStream() {
        if (fileOutput != null) {
            try {
                return Files.newOutputStream(fileOutput.toPath());
            } catch (IOException ex) {
                throw new ApplicationException("Failed to create output: " + fileOutput, ex);
            }
        }
        return new FilterOutputStream(System.out) {
            @Override
            public void close() {
                // Do not close stdout
            }
        };
    }

    /**
     * Check the count is positive, otherwise create an error message for the provided format.
     *
     * @param count The count of numbers to output.
     * @param format The format.
     * @throws ApplicationException If the count is not positive.
     */
    private static void checkCount(long count,
                                   OutputFormat format) {
        if (count <= 0) {
            throw new ApplicationException(format + " format requires a positive count: " + count);
        }
    }

    /**
     * Write int data to the specified output using the dieharder text format.
     *
     * @param rng The random generator.
     * @param out The output.
     * @throws IOException Signals that an I/O exception has occurred.
     * @throws ApplicationException If the count is not positive.
     */
    private void writeDieharder(final UniformRandomProvider rng,
                                final OutputStream out) throws IOException {
        checkCount(count, OutputFormat.DIEHARDER);

        // Use dieharder output, e.g.
        //#==================================================================
        //# generator mt19937  seed = 1
        //#==================================================================
        //type: d
        //count: 1
        //numbit: 32
        //1791095845
        try (BufferedWriter output = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) {
            writeHeaderLine(output);
            output.write("# generator ");
            output.write(rng.toString());
            output.write("  seed = ");
            output.write(createSeedString());
            output.write(NEW_LINE);
            writeHeaderLine(output);
            output.write("type: d");
            output.write(NEW_LINE);
            output.write("count: ");
            output.write(Long.toString(count));
            output.write(NEW_LINE);
            output.write("numbit: 32");
            output.write(NEW_LINE);
            for (long c = 0; c < count; c++) {
                // Unsigned integers
                final String text = Long.toString(rng.nextInt() & 0xffffffffL);
                // Left pad with spaces
                for (int i = 10 - text.length(); i > 0; i--) {
                    output.write(' ');
                }
                output.write(text);
                output.write(NEW_LINE);
            }
        }
    }

    /**
     * Write a header line to the output.
     *
     * @param output the output
     * @throws IOException Signals that an I/O exception has occurred.
     */
    private static void writeHeaderLine(Writer output) throws IOException {
        output.write("#==================================================================");
        output.write(NEW_LINE);
    }

    /**
     * Write raw binary data to the output.
     *
     * @param rng The random generator.
     * @param out The output.
     * @throws IOException Signals that an I/O exception has occurred.
     */
    private void writeBinaryData(final UniformRandomProvider rng,
                                 final OutputStream out) throws IOException {
        // If count is not positive use max value.
        // This is effectively unlimited: program must be killed.
        final long limit = (count < 1) ? Long.MAX_VALUE : count;
        try (RngDataOutput data = RNGUtils.createDataOutput(rng, source64, out, bufferSize, byteOrder)) {
            for (long c = 0; c < limit; c++) {
                data.write(rng);
            }
        }
    }

    /**
     * Write binary bit data to the specified file.
     *
     * @param rng The random generator.
     * @param out The output.
     * @throws IOException Signals that an I/O exception has occurred.
     * @throws ApplicationException If the count is not positive.
     */
    private void writeBitData(final UniformRandomProvider rng,
                              final OutputStream out) throws IOException {
        checkCount(count, OutputFormat.BITS);

        final boolean asLong = rng instanceof RandomLongSource;

        try (BufferedWriter output = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) {
            for (long c = 0; c < count; c++) {
                if (asLong) {
                    writeLong(output, rng.nextLong());
                } else {
                    writeInt(output, rng.nextInt());
                }
            }
        }
    }

    /**
     * Write an {@code long} value to the output. The native Java value will be
     * written to the writer on a single line using: a binary string representation
     * of the bytes; the unsigned integer; and the signed integer.
     *
     * <pre>
     * 10011010 01010011 01011010 11100100 01000111 00010000 01000011 11000101  11120331841399178181 -7326412232310373435
     * </pre>
     *
     * @param out The output.
     * @param value The value.
     * @throws IOException Signals that an I/O exception has occurred.
     */
    @SuppressWarnings("resource")
    static void writeLong(Writer out,
                          long value) throws IOException {

        // Write out as 8 bytes with spaces between them, high byte first.
        writeByte(out, (int)(value >>> 56) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>> 48) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>> 40) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>> 32) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>> 24) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>> 16) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>>  8) & 0xff);
        out.write(' ');
        writeByte(out, (int)(value >>>  0) & 0xff);

        // Write the unsigned and signed int value
        new Formatter(out).format("  %20s %20d%n", Long.toUnsignedString(value), value);
    }

    /**
     * Write an {@code int} value to the output. The native Java value will be
     * written to the writer on a single line using: a binary string representation
     * of the bytes; the unsigned integer; and the signed integer.
     *
     * <pre>
     * 11001101 00100011 01101111 01110000   3441651568  -853315728
     * </pre>
     *
     * @param out The output.
     * @param value The value.
     * @throws IOException Signals that an I/O exception has occurred.
     */
    @SuppressWarnings("resource")
    static void writeInt(Writer out,
                         int value) throws IOException {

        // Write out as 4 bytes with spaces between them, high byte first.
        writeByte(out, (value >>> 24) & 0xff);
        out.write(' ');
        writeByte(out, (value >>> 16) & 0xff);
        out.write(' ');
        writeByte(out, (value >>>  8) & 0xff);
        out.write(' ');
        writeByte(out, (value >>>  0) & 0xff);

        // Write the unsigned and signed int value
        new Formatter(out).format("  %10d %11d%n", value & 0xffffffffL, value);
    }

    /**
     * Write the lower 8 bits of an {@code int} value to the buffered writer using a
     * binary string representation. This is left-filled with zeros if applicable.
     *
     * <pre>
     * 11001101
     * </pre>
     *
     * @param out The output.
     * @param value The value.
     * @throws IOException Signals that an I/O exception has occurred.
     */
    private static void writeByte(Writer out,
                                  int value) throws IOException {
        // This matches the functionality of:
        // data.write(String.format("%8s", Integer.toBinaryString(value & 0xff)).replace(' ', '0'))
        out.write(BIT_REP[value >>> 4]);
        out.write(BIT_REP[value & 0x0F]);
    }
}
