BaseDataObject.java

package emissary.core;

import emissary.core.channels.SeekableByteChannelFactory;
import emissary.core.channels.SeekableByteChannelHelper;
import emissary.directory.DirectoryEntry;
import emissary.pickup.Priority;
import emissary.util.ByteUtil;
import emissary.util.PayloadUtil;

import com.google.common.collect.LinkedListMultimap;
import jakarta.annotation.Nullable;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.SeekableByteChannel;
import java.rmi.Remote;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;

/**
 * Class to hold data, header, footer, and attributes
 */
public class BaseDataObject implements Serializable, Cloneable, Remote, IBaseDataObject {
    protected static final Logger logger = LoggerFactory.getLogger(BaseDataObject.class);

    /* Used to limit the size of a returned byte array to avoid certain edge case scenarios */
    public static final int MAX_BYTE_ARRAY_SIZE = Integer.MAX_VALUE - 8;

    /* Including this here make serialization of this object faster. */
    private static final long serialVersionUID = 7362181964652092657L;

    /* Actual data - migrate away from this towards byte channels. */
    @Nullable
    protected byte[] theData;

    /**
     * Original name of the input data. Can only be set in the constructor of the DataObject. returned via the
     * {@link #getFilename()} method. Also used in constructing the {@link #shortName()} of the document.
     */
    protected String theFileName;

    /**
     * Terminal portion of theFileName
     */
    protected String shortName;

    /**
     * The internal identifier, generated for each constructed object
     */
    protected UUID internalId = UUID.randomUUID();

    /**
     * The currentForm is a stack of the itinerary items. The contents of the list are {@link String} and map to the
     * dataType portion of the keys in the emissary.DirectoryPlace.
     */
    protected List<String> currentForm = new ArrayList<>();

    /**
     * History of processing errors. Lines of text are accumulated from String and returned in-toto as a String.
     */
    protected StringBuilder procError;

    /**
     * A travelogue built up as the agent moves about. Appended to by the agent as it goes from place to place.
     */
    protected TransformHistory history = new TransformHistory();

    /**
     * The last determined language(characterset) of the data.
     */
    @Nullable
    protected String fontEncoding = null;

    /**
     * Dynamic facets or metadata attributes of the data
     */
    protected LinkedListMultimap<String, Object> parameters = LinkedListMultimap.create(100);

    /**
     * If this file caused other agents to be sprouted, indicate how many
     */
    protected int numChildren = 0;

    /**
     * If this file has siblings that were sprouted at the same time, this will indicate how many total siblings there are.
     * This can be used to navigate among siblings without needing to refer to the parent.
     */
    protected int numSiblings = 0;

    /**
     * What child is this in the family order
     */
    protected int birthOrder = 0;

    /**
     * Hash of alternate views of the data {@link String} current form is the key, byte[] is the value
     */
    protected Map<String, byte[]> multipartAlternative = new TreeMap<>();

    /**
     * Any header that goes along with the data
     */
    @Nullable
    protected byte[] header = null;

    /**
     * Any footer that goes along with the data
     */
    @Nullable
    protected byte[] footer = null;

    /**
     * If the header has some encoding scheme record it
     */
    @Nullable
    protected String headerEncoding = null;

    /**
     * Record the classification scheme for the document
     */
    @Nullable
    protected String classification = null;

    /**
     * Keep track of if and how the document is broken so we can report on it later
     */
    @Nullable
    protected StringBuilder brokenDocument = null;

    // Filetypes that we think are equivalent to no file type at all
    protected String[] emptyFileTypes = {Form.UNKNOWN};

    /**
     * The integer priority of the data object. A lower number is higher priority.
     */
    protected int priority = Priority.DEFAULT;

    /**
     * The timestamp for when the BaseDataObject was created. Used in data provenance tracking.
     */
    protected Instant creationTimestamp;

    /**
     * The extracted records, if any
     */
    @Nullable
    protected List<IBaseDataObject> extractedRecords;

    /**
     * Check to see if this tree is able to be written out.
     */
    protected boolean outputable = true;

    /**
     * The unique identifier of this object
     */
    protected String id;

    /**
     * The identifier of the {@link emissary.pickup.WorkBundle}
     */
    protected String workBundleId;

    /**
     * The identifier used to track the object through the system
     */
    protected String transactionId;

    /**
     * A factory to create channels for the referenced data.
     */
    @Nullable
    protected SeekableByteChannelFactory seekableByteChannelFactory;

    @Nullable
    protected final IBaseDataObject tld;

    protected enum DataState {
        NO_DATA, CHANNEL_ONLY, BYTE_ARRAY_ONLY, BYTE_ARRAY_AND_CHANNEL
    }

    protected static final String INVALID_STATE_MSG = "Can't have both theData and seekableByteChannelFactory set. Object is %s";

    /**
     * <p>
     * Determine what state we're in with respect to the byte[] of data vs a channel.
     * </p>
     * 
     * <p>
     * Not exposed publicly as consumers should be moving to channels, meaning ultimately the states will be simply either a
     * channel factory exists or does not exist.
     * </p>
     * 
     * <p>
     * Consumers should not modify their behaviour based on the state of the BDO, if they're being modified to handle
     * channels, they should only handle channels, not both channels and byte[].
     * </p>
     * 
     * @return the {@link DataState} of this BDO
     */
    protected DataState getDataState() {
        if (theData == null) {
            if (seekableByteChannelFactory == null) {
                return DataState.NO_DATA;
            } else {
                return DataState.CHANNEL_ONLY;
            }
        } else {
            if (seekableByteChannelFactory == null) {
                return DataState.BYTE_ARRAY_ONLY;
            } else {
                return DataState.BYTE_ARRAY_AND_CHANNEL;
            }
        }
    }

    /**
     * Create an empty BaseDataObject.
     */
    public BaseDataObject() {
        this.theData = null;
        setCreationTimestamp(Instant.now());
        tld = null;
    }

    /**
     * Create a new BaseDataObject with byte array and name passed in. WARNING: this implementation uses the passed in array
     * directly, no copy is made so the caller should not reuse the array.
     *
     * @param newData the bytes to hold
     * @param name the name of the data item
     */
    public BaseDataObject(final byte[] newData, final String name) {
        setData(newData);
        setFilename(name);
        setCreationTimestamp(Instant.now());
        tld = null;
    }

    /**
     * Create a new BaseDataObject with byte array, name, and initial form WARNING: this implementation uses the passed in
     * array directly, no copy is made so the caller should not reuse the array.
     *
     * @param newData the bytes to hold
     * @param name the name of the data item
     * @param form the initial form of the data
     */
    public BaseDataObject(final byte[] newData, final String name, @Nullable final String form) {
        this(newData, name);
        if (form != null) {
            pushCurrentForm(form);
        }
    }

    public BaseDataObject(final byte[] newData, final String name, final String form, @Nullable final String fileType) {
        this(newData, name, form);
        if (fileType != null) {
            this.setFileType(fileType);
        }
    }

    public BaseDataObject(final byte[] newData, final String name, @Nullable final String form, IBaseDataObject tld) {
        setData(newData);
        setFilename(name);
        setCreationTimestamp(Instant.now());
        if (form != null) {
            pushCurrentForm(form);
        }
        this.tld = tld;
    }

    public BaseDataObject(final byte[] newData, final String name, @Nullable final String form, @Nullable final String fileType,
            IBaseDataObject tld) {
        this(newData, name, form, tld);
        if (fileType != null) {
            this.setFileType(fileType);
        }
    }

    /**
     * Set the header byte array WARNING: this implementation uses the passed in array directly, no copy is made so the
     * caller should not reuse the array.
     *
     * @param header the byte array of header data
     */
    @Override
    public void setHeader(final byte[] header) {
        this.header = header;
    }

    /**
     * Get the value of headerEncoding. Tells how to interpret the header information.
     *
     * @return Value of headerEncoding.
     */
    @Override
    public String getHeaderEncoding() {
        return this.headerEncoding;
    }

    /**
     * Set the value of headerEncoding for proper interpretation and processing later
     *
     * @param v Value to assign to headerEncoding.
     */
    @Override
    public void setHeaderEncoding(final String v) {
        this.headerEncoding = v;
    }

    /**
     * Set the footer byte array WARNING: this implementation uses the passed in array directly, no copy is made so the
     * caller should not reuse the array.
     *
     * @param footer byte array of footer data
     */
    @Override
    public void setFooter(final byte[] footer) {
        this.footer = footer;
    }

    /**
     * Set the filename
     *
     * @param f the new name of the data including path
     */
    @Override
    public void setFilename(final String f) {
        this.theFileName = f;
        this.shortName = makeShortName();
    }

    /**
     * Set the byte channel factory using whichever implementation is providing access to the data.
     * 
     * Setting this will null out {@link #theData}
     */
    @Override
    public void setChannelFactory(final SeekableByteChannelFactory sbcf) {
        Validate.notNull(sbcf, "Required: SeekableByteChannelFactory not null");
        this.theData = null;
        this.seekableByteChannelFactory = sbcf;
    }

    /**
     * Returns the seekable byte channel factory containing a reference to the data, or wraps the in-memory data on the BDO
     * in a new factory.
     * 
     * @return the factory containing the data reference or the data wrapped in a new factory
     */
    @Nullable
    @Override
    @SuppressWarnings("UnnecessaryDefaultInEnumSwitch")
    public SeekableByteChannelFactory getChannelFactory() {
        switch (getDataState()) {
            case BYTE_ARRAY_AND_CHANNEL:
                throw new IllegalStateException(String.format(INVALID_STATE_MSG, shortName()));
            case CHANNEL_ONLY:
                return seekableByteChannelFactory;
            case BYTE_ARRAY_ONLY:
                return SeekableByteChannelHelper.memory(this.theData);
            case NO_DATA:
            default:
                return null;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Nullable
    @Override
    public InputStream newInputStream() {
        final SeekableByteChannelFactory sbcf = getChannelFactory();

        return sbcf == null ? null : Channels.newInputStream(sbcf.create());
    }

    /**
     * <p>
     * Return BaseDataObjects byte array OR as much as we can from the reference to the data up to MAX_BYTE_ARRAY_SIZE.
     * </p>
     * 
     * <p>
     * Data returned from a backing Channel will be truncated at {@link BaseDataObject#MAX_BYTE_ARRAY_SIZE}. Using
     * channel-related methods is now preferred to allow handling of larger objects
     * </p>
     * 
     * <p>
     * <b>WARNING</b>: There is no way for the caller to know whether the data being returned is the direct array held in
     * memory, or a copy of the data from a byte channel factory, so the returned byte array should be treated as live and
     * not be modified.
     * </p>
     * 
     * @see #getChannelFactory()
     * @return the data as a byte array
     */
    @Nullable
    @Override
    @SuppressWarnings("UnnecessaryDefaultInEnumSwitch")
    public byte[] data() {
        switch (getDataState()) {
            case BYTE_ARRAY_AND_CHANNEL:
                throw new IllegalStateException(String.format(INVALID_STATE_MSG, shortName()));
            case BYTE_ARRAY_ONLY:
                return theData;
            case CHANNEL_ONLY:
                // Max size here is slightly less than the true max size to avoid memory issues
                return SeekableByteChannelHelper.getByteArrayFromBdo(this, MAX_BYTE_ARRAY_SIZE);
            case NO_DATA:
            default:
                return null; // NOSONAR maintains backwards compatibility
        }
    }

    /**
     * @see #setData(byte[], int, int)
     */
    @Override
    public void setData(@Nullable final byte[] newData) {
        this.seekableByteChannelFactory = null;
        this.theData = newData == null ? new byte[0] : newData;
    }

    /**
     * <p>
     * Set new data on the BDO, using a range of the provided byte array. This will remove the reference to any byte channel
     * factory that backs this BDO so be careful!
     * </p>
     * 
     * <p>
     * Limited in size to 2^31. Use channel-based methods for larger data.
     * </p>
     * 
     * @param newData containing the source of the new data
     * @param offset where to start copying from
     * @param length how much to copy
     * @see #setChannelFactory(SeekableByteChannelFactory)
     */
    @Override
    public void setData(@Nullable final byte[] newData, final int offset, final int length) {
        this.seekableByteChannelFactory = null;
        if (length <= 0 || newData == null) {
            this.theData = new byte[0];
        } else {
            this.theData = new byte[length];
            System.arraycopy(newData, offset, this.theData, 0, length);
        }
    }

    /**
     * Checks if the data is defined with a non-zero length.
     * 
     * @return if data is undefined or zero length.
     */
    @Override
    public boolean hasContent() throws IOException {
        return getChannelSize() > 0;
    }

    /**
     * Convenience method to get the size of the channel or byte array providing access to the data.
     * 
     * @return the channel size
     */
    @Override
    @SuppressWarnings("UnnecessaryDefaultInEnumSwitch")
    public long getChannelSize() throws IOException {
        switch (getDataState()) {
            case BYTE_ARRAY_AND_CHANNEL:
                throw new IllegalStateException(String.format(INVALID_STATE_MSG, shortName()));
            case BYTE_ARRAY_ONLY:
                return ArrayUtils.getLength(theData);
            case CHANNEL_ONLY:
                try (SeekableByteChannel sbc = this.seekableByteChannelFactory.create()) {
                    return sbc.size();
                }
            case NO_DATA:
            default:
                return 0;
        }
    }

    /**
     * Fetch the size of the payload. Prefer to use: {@link #getChannelSize}
     * 
     * @return the length of theData, or the size of the seekable byte channel up to
     *         {@link BaseDataObject#MAX_BYTE_ARRAY_SIZE}.
     */
    @Override
    @SuppressWarnings("UnnecessaryDefaultInEnumSwitch")
    public int dataLength() {
        switch (getDataState()) {
            case BYTE_ARRAY_AND_CHANNEL:
                throw new IllegalStateException(String.format(INVALID_STATE_MSG, shortName()));
            case BYTE_ARRAY_ONLY:
                return ArrayUtils.getLength(theData);
            case CHANNEL_ONLY:
                try {
                    return (int) Math.min(getChannelSize(), MAX_BYTE_ARRAY_SIZE);
                } catch (final IOException ioe) {
                    logger.error("Couldn't get size of channel on object {}", shortName(), ioe);
                    return 0;
                }
            case NO_DATA:
            default:
                return 0;
        }
    }

    @Override
    public String shortName() {
        return this.shortName;
    }

    /**
     * Construct the shortname
     */
    private String makeShortName() {
        /*
         * using the file object works for most cases. It fails on the unix side if it is given a valid Windows path.
         */
        // File file = new File( theFileName );
        // return file.getName();
        // so..... we'll have to perform the check ourselves ARRRRRRRRRGH!!!!
        final int unixPathIndex = this.theFileName.lastIndexOf("/");
        if (unixPathIndex >= 0) {
            return this.theFileName.substring(unixPathIndex + 1);
        }
        // check for windows path
        final int windowsPathIndex = this.theFileName.lastIndexOf("\\");
        if (windowsPathIndex >= 0) {
            return this.theFileName.substring(windowsPathIndex + 1);
        }

        return this.theFileName;
    }

    @Override
    public String getFilename() {
        return this.theFileName;
    }

    @Override
    public String currentForm() {
        return currentFormAt(0);
    }

    @Override
    public String currentFormAt(final int i) {
        if (i < this.currentForm.size()) {
            return this.currentForm.get(i);
        }
        return "";
    }

    @Override
    public int searchCurrentForm(final String value) {
        return this.currentForm.indexOf(value);
    }

    @Nullable
    @Override
    public String searchCurrentForm(final Collection<String> values) {
        for (final String value : values) {
            if (this.currentForm.contains(value)) {
                return value;
            }
        }
        return null;
    }

    @Override
    public int currentFormSize() {
        return this.currentForm.size();
    }

    @Override
    public void replaceCurrentForm(@Nullable final String form) {
        this.currentForm.clear();
        if (form != null) {
            pushCurrentForm(form);
        }
    }

    /**
     * Remove a form from the head of the list
     *
     * @return The value that was removed, or {@code null} if the list was empty.
     */
    @Nullable
    @Override
    public String popCurrentForm() {
        if (this.currentForm.isEmpty()) {
            return null;
        } else {
            return this.currentForm.remove(0);
        }
    }

    @Override
    public int deleteCurrentForm(final String form) {
        int count = 0;

        if (this.currentForm == null || this.currentForm.isEmpty()) {
            return count;
        }

        // Remove all matching
        for (final Iterator<String> i = this.currentForm.iterator(); i.hasNext();) {
            final String val = i.next();
            if (val.equals(form)) {
                i.remove();
                count++;
            }
        }
        return count;
    }

    @Override
    public int deleteCurrentFormAt(final int i) {
        // Make sure its a legal position.
        if ((i >= 0) && (i < this.currentForm.size())) {
            this.currentForm.remove(i);
        }
        return this.currentForm.size();
    }

    @Override
    public int addCurrentFormAt(final int i, final String newForm) {
        if (newForm == null) {
            throw new IllegalArgumentException("caller attempted to add a null form value at position " + i);
        }

        checkForAndLogDuplicates(newForm, "addCurrentFormAt");
        if (i < this.currentForm.size()) {
            this.currentForm.add(i, newForm);
        } else {
            this.currentForm.add(newForm);
        }
        return this.currentForm.size();
    }

    @Override
    public int enqueueCurrentForm(final String newForm) {
        if (newForm == null) {
            throw new IllegalArgumentException("caller attempted to enqueue a null form value");
        }

        checkForAndLogDuplicates(newForm, "enqueueCurrentForm");
        this.currentForm.add(newForm);
        return this.currentForm.size();
    }

    @Override
    public int pushCurrentForm(final String newForm) {
        if (newForm == null) {
            throw new IllegalArgumentException("caller attempted to push a null form value");
        } else if (!PayloadUtil.isValidForm(newForm)) {
            // If there is a key separator in the form, then throw an error log as this will cause issues in routing
            logger.error("INVALID FORM: The form can only contain a-z, A-Z, 0-9, '-', '_', '()', '/', '+'. Given form: {}", newForm);
        }

        checkForAndLogDuplicates(newForm, "pushCurrentForm");
        return addCurrentFormAt(0, newForm);
    }

    @Override
    public void setCurrentForm(final String newForm) {
        setCurrentForm(newForm, false);
    }

    @Override
    public void setCurrentForm(final String newForm, final boolean clearAllForms) {
        if (StringUtils.isBlank(newForm)) {
            throw new IllegalArgumentException("caller attempted to set the current form to a null value");
        }

        if (clearAllForms) {
            replaceCurrentForm(newForm);
        } else {
            popCurrentForm();
            pushCurrentForm(newForm);
        }
    }


    @Override
    public List<String> getAllCurrentForms() {
        return new ArrayList<>(this.currentForm);
    }

    @Override
    public void pullFormToTop(final String curForm) {
        if (this.currentForm.size() > 1) {
            // Delete it
            final int count = deleteCurrentForm(curForm);

            // If deleted, add it back on top
            if (count > 0) {
                this.currentForm.add(0, curForm);
            }
        }
    }

    private void checkForAndLogDuplicates(String newForm, String method) {
        if (currentForm.contains(newForm)) {
            logger.info("Duplicate form {} being added through BaseDataObject.{}", newForm, method);
        }
    }

    @Override
    public String toString() {
        final StringBuilder myOutput = new StringBuilder();
        final String ls = System.getProperty("line.separator");

        myOutput.append(ls);
        myOutput.append("   currentForms: ").append(getAllCurrentForms()).append(ls);
        myOutput.append("   ").append(history);

        return myOutput.toString();
    }

    @Override
    public String printMeta() {
        return PayloadUtil.printFormattedMetadata(this);
    }

    @Override
    public void addProcessingError(final String err) {
        if (this.procError == null) {
            this.procError = new StringBuilder();
        }
        this.procError.append(err).append("\n");
    }

    @Override
    public String getProcessingError() {
        String s = null;
        if (this.procError != null) {
            s = this.procError.toString();
        }
        return s;
    }

    @Override
    public TransformHistory getTransformHistory() {
        return new TransformHistory(history);
    }

    @Override
    public List<String> transformHistory() {
        return transformHistory(false);
    }

    @Override
    public List<String> transformHistory(boolean includeCoordinated) {
        return history.get(includeCoordinated);
    }

    @Override
    public void clearTransformHistory() {
        this.history.clear();
    }

    @Override
    public void appendTransformHistory(final String key) {
        appendTransformHistory(key, false);
    }

    @Override
    public void appendTransformHistory(final String key, boolean coordinated) {
        this.history.append(key, coordinated);
    }

    @Override
    public void setHistory(TransformHistory newHistory) {
        this.history.set(newHistory);
    }

    @Override
    public String whereAmI() {
        String host = null;
        try {
            host = InetAddress.getLocalHost().getCanonicalHostName();
        } catch (UnknownHostException e) {
            host = "FAILED";
        }
        return host;
    }

    @Nullable
    @Override
    public DirectoryEntry getLastPlaceVisited() {
        TransformHistory.History entry = history.lastVisit();
        return entry == null ? null : new DirectoryEntry(entry.getKey());
    }

    @Nullable
    @Override
    public DirectoryEntry getPenultimatePlaceVisited() {
        TransformHistory.History entry = history.penultimateVisit();
        return entry == null ? null : new DirectoryEntry(entry.getKey());
    }

    @Override
    public boolean hasVisited(final String pattern) {
        return history.hasVisited(pattern);
    }

    @Override
    public boolean beforeStart() {
        return history.beforeStart();
    }

    @Override
    public void clearParameters() {
        this.parameters.clear();
    }

    @Override
    public boolean hasParameter(final String key) {
        return this.parameters.containsKey(key);
    }

    @Override
    public void setParameters(final Map<? extends String, ? extends Object> map) {
        this.parameters.clear();
        putParameters(map);
    }

    @Override
    public void setParameter(final String key, final Object val) {
        deleteParameter(key);
        putParameter(key, val);
    }

    @Override
    public void putParameter(final String key, final Object val) {
        this.parameters.removeAll(key);

        if (val instanceof Iterable) {
            this.parameters.putAll(key, (Iterable<?>) val);
        } else {
            this.parameters.put(key, val);
        }
    }

    /**
     * Put a collection of parameters into the metadata map, keeping both old and new values
     *
     * @param m the map of new parameters
     */
    @Override
    public void putParameters(final Map<? extends String, ? extends Object> m) {
        putParameters(m, MergePolicy.KEEP_ALL);
    }

    /**
     * Merge in new parameters using the specified policy to determine whether to keep all values, unique values, or prefer
     * existing values
     *
     * @param m map of new parameters
     * @param policy the merge policy
     */
    @Override
    public void putParameters(final Map<? extends String, ? extends Object> m, final MergePolicy policy) {
        for (final Map.Entry<? extends String, ? extends Object> entry : m.entrySet()) {
            final String name = entry.getKey();

            if ((policy == MergePolicy.KEEP_EXISTING) && this.parameters.containsKey(name)) {
                continue;
            }

            final Object value = entry.getValue();

            if (policy == MergePolicy.DROP_EXISTING) {
                // store the provided value for this key, discarding any previously-stored value
                setParameter(name, value);
                continue;
            }

            if (value instanceof Iterable) {
                for (final Object v : (Iterable<?>) value) {
                    if (policy == MergePolicy.KEEP_ALL || policy == MergePolicy.KEEP_EXISTING) {
                        this.parameters.put(name, v);
                    } else if (policy == MergePolicy.DISTINCT) {
                        if (!this.parameters.containsEntry(name, v)) {
                            this.parameters.put(name, v);
                        }
                    } else {
                        throw new IllegalStateException("Unhandled parameter merge policy " + policy + " for " + name);
                    }
                }
            } else {
                if (policy == MergePolicy.KEEP_ALL || policy == MergePolicy.KEEP_EXISTING) {
                    this.parameters.put(name, value);
                } else if (policy == MergePolicy.DISTINCT) {
                    if (!this.parameters.containsEntry(name, value)) {
                        this.parameters.put(name, value);
                    }
                } else {
                    throw new IllegalStateException("Unhandled parameter merge policy " + policy + " for " + name);
                }
            }
        }
    }


    /**
     * Put a collection of parameters into the metadata map, adding only distinct k/v pairs
     *
     * @param m the map of new parameters
     */
    @Override
    public void putUniqueParameters(final Map<? extends String, ? extends Object> m) {
        putParameters(m, MergePolicy.DISTINCT);
    }

    /**
     * Merge in parameters keeping existing keys unchanged
     *
     * @param m map of new parameters to consider
     */
    @Override
    public void mergeParameters(final Map<? extends String, ? extends Object> m) {
        putParameters(m, MergePolicy.KEEP_EXISTING);
    }

    @Nullable
    @Override
    public List<Object> getParameter(final String key) {
        // Try remapping
        List<Object> v = this.parameters.get(key);
        if (CollectionUtils.isEmpty(v)) {
            return null;
        }
        return v;
    }

    @Override
    public void appendParameter(final String key, final CharSequence value) {
        this.parameters.put(key, value);
    }

    @Override
    public void appendParameter(final String key, final Iterable<? extends CharSequence> values) {
        this.parameters.putAll(key, values);
    }

    /**
     * Append data to the specified metadata element if it doesn't already exist If you expect to append a lot if things
     * this way, this method might not have the performance characteristics that you expect. You can build a set and
     * externally and append the values after they are uniqued.
     *
     * @param key name of the metadata element
     * @param value the value to append
     * @return true if the item is added, false if it already exists
     */
    @Override
    public boolean appendUniqueParameter(final String key, final CharSequence value) {
        if (this.parameters.containsEntry(key, value)) {
            return false;
        }

        this.parameters.put(key, value);
        return true;
    }

    @Nullable
    @Override
    public String getParameterAsString(final String key) {
        final var obj = getParameterAsStrings(key);
        if (obj.size() > 1) {
            logger.warn("Multiple values for parameter, parameter:{}, number of values:{}", key, obj.size());
            return getParameterAsConcatString(key);
        }
        return obj.stream().findFirst().orElse(null);
    }

    /**
     * Retrieve all the metadata elements of this object This method returns possibly mapped metadata element names
     *
     * @return map of metadata elements
     */
    @Override
    public Map<String, Collection<Object>> getParameters() {
        return this.parameters.asMap();
    }

    /**
     * Get a processed represenation of the parameters for external use
     */
    @Override
    public Map<String, String> getCookedParameters() {
        final Map<String, String> ext = new TreeMap<>();
        for (final String key : this.parameters.keySet()) {
            ext.put(key.toString(), getStringParameter(key));
        }
        return ext;
    }

    @Override
    public Set<String> getParameterKeys() {
        return this.parameters.keySet();
    }

    @Override
    public List<Object> deleteParameter(final String key) {
        return this.parameters.removeAll(key);
    }

    @Override
    public void setNumChildren(final int num) {
        this.numChildren = num;
    }

    @Override
    public void setNumSiblings(final int num) {
        this.numSiblings = num;
    }

    @Override
    public void setBirthOrder(final int num) {
        this.birthOrder = num;
    }

    @Override
    public int getNumChildren() {
        return this.numChildren;
    }

    @Override
    public int getNumSiblings() {
        return this.numSiblings;
    }

    @Override
    public int getBirthOrder() {
        return this.birthOrder;
    }

    /**
     * Return a reference to the header byte array. WARNING: this implementation returns the actual array directly, no copy
     * is made so the caller must be aware that modifications to the returned array are live.
     *
     * @return byte array of header information or null if none
     */
    @Override
    public byte[] header() {
        return this.header;
    }

    @Override
    @Deprecated
    public ByteBuffer headerBuffer() {
        return ByteBuffer.wrap(header());
    }

    /**
     * Return a reference to the footer byte array. WARNING: this implementation returns the actual array directly, no copy
     * is made so the caller must be aware that modifications to the returned array are live.
     *
     * @return byte array of footer data or null if none
     */
    @Override
    public byte[] footer() {
        return this.footer;
    }


    @Override
    @Deprecated
    public ByteBuffer footerBuffer() {
        return ByteBuffer.wrap(footer());
    }

    @Override
    @Deprecated
    public ByteBuffer dataBuffer() {
        return ByteBuffer.wrap(data());
    }

    @Override
    public String getFontEncoding() {
        return this.fontEncoding;
    }

    @Override
    public void setFontEncoding(final String fe) {
        this.fontEncoding = fe;
    }

    private static final String FILETYPE = "FILETYPE";

    /**
     * Put the FILETYPE parameter, null to clear
     *
     * @param v the value to store or null
     */
    @Override
    public void setFileType(@Nullable final String v) {
        deleteParameter(FILETYPE);
        if (v != null) {
            setParameter(FILETYPE, v);
        }
    }

    @Override
    @Deprecated
    public boolean setFileTypeIfEmpty(final String v, final String[] empties) {
        if (isFileTypeEmpty(empties)) {
            setFileType(v);
            return true;
        }
        return false;
    }

    @Override
    public boolean setFileTypeIfEmpty(final String v) {
        return setFileTypeIfEmpty(v, this.emptyFileTypes);
    }

    @Override
    public boolean isFileTypeEmpty() {
        return isFileTypeEmpty(this.emptyFileTypes);
    }

    /**
     * Return true if the file type is null or in one of the specified set of empties
     *
     * @param empties a list of types that count as empty
     */
    protected boolean isFileTypeEmpty(@Nullable final String[] empties) {
        final String s = getFileType();

        if (StringUtils.isEmpty(s)) {
            return true;
        }

        if (s.endsWith(Form.SUFFIXES_UNWRAPPED)) {
            return true;
        }

        for (int i = 0; empties != null && i < empties.length; i++) {
            if (s.equals(empties[i])) {
                return true;
            }
        }
        return false;
    }

    @Override
    public String getFileType() {
        return getStringParameter(FILETYPE);
    }

    @Override
    public int getNumAlternateViews() {
        return this.multipartAlternative.size();
    }

    /**
     * Return a specified multipart alternative view of the data WARNING: this implementation returns the actual array
     * directly, no copy is made so the caller must be aware that modifications to the returned array are live.
     *
     * @param s the name of the view to retrieve
     * @return byte array of alternate view data or null if none
     */
    @Override
    public byte[] getAlternateView(final String s) {
        return this.multipartAlternative.get(s);
    }

    @Override
    public void appendAlternateView(final String name, final byte[] data) {
        appendAlternateView(name, data, 0, data.length);
    }

    @Override
    public void appendAlternateView(final String name, final byte[] data, final int offset, final int length) {
        final byte[] av = getAlternateView(name);
        if (av != null) {
            addAlternateView(name, ByteUtil.glue(av, 0, av.length - 1, data, offset, offset + length - 1));
        } else {
            addAlternateView(name, data, offset, length);
        }
    }

    /**
     * Return a specified multipart alternative view of the data in a buffer
     *
     * @param s the name of the view to retrieve
     * @return buffer of alternate view data or null if none
     */
    @Nullable
    @Override
    @Deprecated
    public ByteBuffer getAlternateViewBuffer(final String s) {
        final byte[] viewdata = getAlternateView(s);
        if (viewdata == null) {
            return null;
        }
        return ByteBuffer.wrap(viewdata);
    }

    /**
     * Add a multipart alternative view of the data WARNING: this implementation returns the actual array directly, no copy
     * is made so the caller must be aware that modifications to the returned array are live.
     *
     * @param name the name of the new view
     * @param data the byte array of data for the view
     */
    @Override
    public void addAlternateView(final String name, @Nullable final byte[] data) {
        if (data == null) {
            this.multipartAlternative.remove(name);
        } else {
            this.multipartAlternative.put(name, data);
        }
    }

    @Override
    public void addAlternateView(final String name, @Nullable final byte[] data, final int offset, final int length) {
        if (data == null || length <= 0) {
            this.multipartAlternative.remove(name);
        } else {
            final byte[] mpa = new byte[length];
            System.arraycopy(data, offset, mpa, 0, length);
            this.multipartAlternative.put(name, mpa);
        }
    }

    /**
     * {@inheritDoc}
     *
     * @return an ordered set of alternate view names
     */
    @Override
    public Set<String> getAlternateViewNames() {
        return new TreeSet<>(this.multipartAlternative.keySet());
    }

    /**
     * Get the alternate view map. WARNING: this implementation returns the actual map directly, no copy is made so the
     * caller must be aware that modifications to the returned map are live.
     *
     * @return an map of alternate views ordered by name, key = String, value = byte[]
     */
    @Override
    public Map<String, byte[]> getAlternateViews() {
        return this.multipartAlternative;
    }

    @Override
    public boolean isBroken() {
        return this.brokenDocument != null;
    }

    @Override
    public void setBroken(@Nullable final String v) {
        if (v == null) {
            this.brokenDocument = null;
            return;
        }

        if (this.brokenDocument == null) {
            this.brokenDocument = new StringBuilder();
            this.brokenDocument.append(v);
        } else {
            this.brokenDocument.append(", ").append(v);
        }
    }

    @Nullable
    @Override
    public String getBroken() {
        if (this.brokenDocument == null) {
            return null;
        }
        return this.brokenDocument.toString();
    }

    @Override
    public void setClassification(final String classification) {
        this.classification = classification;
    }

    @Override
    public String getClassification() {
        return this.classification;
    }

    @Override
    public int getPriority() {
        return this.priority;
    }

    @Override
    public void setPriority(final int priority) {
        this.priority = priority;
    }

    /**
     * Clone this payload
     */
    @Deprecated
    @Override
    public IBaseDataObject clone() throws CloneNotSupportedException {
        final BaseDataObject c = (BaseDataObject) super.clone();
        if ((this.theData != null) && (this.theData.length > 0)) {
            c.setData(this.theData, 0, this.theData.length);
        }

        if (this.seekableByteChannelFactory != null) {
            c.setChannelFactory(this.seekableByteChannelFactory);
        }

        c.currentForm = new ArrayList<>(this.currentForm);
        c.history = new TransformHistory(this.history);
        c.multipartAlternative = new HashMap<>(this.multipartAlternative);
        c.priority = this.priority;
        c.creationTimestamp = this.creationTimestamp;

        if ((this.extractedRecords != null) && !this.extractedRecords.isEmpty()) {
            c.clearExtractedRecords(); // remove super.clone copy
            for (final IBaseDataObject r : this.extractedRecords) {
                c.addExtractedRecord(r.clone());
            }
        }
        // This creates a deep copy Guava style
        c.parameters = LinkedListMultimap.create(this.parameters);

        return c;
    }

    @Override
    public Instant getCreationTimestamp() {
        return this.creationTimestamp;
    }

    /**
     * The creation timestamp is part of the provenance of the event represented by this instance. It is normally set from
     * the constructor
     *
     * @param creationTimestamp when this item was created
     */
    @Override
    public void setCreationTimestamp(final Instant creationTimestamp) {
        if (creationTimestamp == null) {
            throw new IllegalArgumentException("Timestamp must not be null");
        }

        this.creationTimestamp = creationTimestamp;
    }

    @Override
    public List<IBaseDataObject> getExtractedRecords() {
        return this.extractedRecords;
    }

    @Override
    public void setExtractedRecords(final List<? extends IBaseDataObject> records) {
        if (records == null) {
            throw new IllegalArgumentException("Record list must not be null");
        }

        for (final IBaseDataObject r : records) {
            if (r == null) {
                throw new IllegalArgumentException("No added record may be null");
            }
        }

        this.extractedRecords = new ArrayList<>(records);
    }

    @Override
    public void addExtractedRecord(final IBaseDataObject record) {
        if (record == null) {
            throw new IllegalArgumentException("Added record must not be null");
        }

        if (this.extractedRecords == null) {
            this.extractedRecords = new ArrayList<>();
        }

        this.extractedRecords.add(record);
    }

    @Override
    public void addExtractedRecords(final List<? extends IBaseDataObject> records) {
        if (records == null) {
            throw new IllegalArgumentException("ExtractedRecord list must not be null");
        }

        for (final IBaseDataObject r : records) {
            if (r == null) {
                throw new IllegalArgumentException("No ExctractedRecord item may be null");
            }
        }

        if (this.extractedRecords == null) {
            this.extractedRecords = new ArrayList<>();
        }

        this.extractedRecords.addAll(records);
    }

    @Override
    public boolean hasExtractedRecords() {
        return (this.extractedRecords != null) && !this.extractedRecords.isEmpty();
    }

    @Override
    public void clearExtractedRecords() {
        this.extractedRecords = null;
    }

    @Override
    public int getExtractedRecordCount() {
        return (this.extractedRecords == null) ? 0 : this.extractedRecords.size();
    }

    @Override
    public UUID getInternalId() {
        return this.internalId;
    }

    @Override
    public boolean isOutputable() {
        return outputable;
    }

    @Override
    public void setOutputable(boolean outputable) {
        this.outputable = outputable;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String getWorkBundleId() {
        return workBundleId;
    }

    @Override
    public void setWorkBundleId(String workBundleId) {
        this.workBundleId = workBundleId;
    }

    @Override
    public String getTransactionId() {
        return transactionId;
    }

    @Override
    public void setTransactionId(String transactionId) {
        this.transactionId = transactionId;
    }

    @Override
    public IBaseDataObject getTld() {
        return tld;
    }

}