PrometheusDataValuesFormatter.java

/*
 * Prometheus: Application Framework
 * Copyright 2012-2026. Tony Washer
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License.  You may obtain a copy
 * of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package io.github.tonywasher.joceanus.prometheus.data;

import io.github.tonywasher.joceanus.gordianknot.api.base.GordianException;
import io.github.tonywasher.joceanus.gordianknot.api.factory.GordianFactory.GordianFactoryLock;
import io.github.tonywasher.joceanus.gordianknot.api.zip.GordianZipFactory;
import io.github.tonywasher.joceanus.gordianknot.api.zip.GordianZipFileContents;
import io.github.tonywasher.joceanus.gordianknot.api.zip.GordianZipFileEntry;
import io.github.tonywasher.joceanus.gordianknot.api.zip.GordianZipLock;
import io.github.tonywasher.joceanus.gordianknot.api.zip.GordianZipReadFile;
import io.github.tonywasher.joceanus.gordianknot.api.zip.GordianZipWriteFile;
import io.github.tonywasher.joceanus.metis.data.MetisDataDifference;
import io.github.tonywasher.joceanus.metis.field.MetisFieldItem.MetisFieldSetDef;
import io.github.tonywasher.joceanus.metis.list.MetisListKey;
import io.github.tonywasher.joceanus.metis.toolkit.MetisToolkit;
import io.github.tonywasher.joceanus.oceanus.base.OceanusException;
import io.github.tonywasher.joceanus.oceanus.format.OceanusDataFormatter;
import io.github.tonywasher.joceanus.oceanus.profile.OceanusProfile;
import io.github.tonywasher.joceanus.prometheus.data.PrometheusDataValues.PrometheusGroupedItem;
import io.github.tonywasher.joceanus.prometheus.exc.PrometheusDataException;
import io.github.tonywasher.joceanus.prometheus.exc.PrometheusIOException;
import io.github.tonywasher.joceanus.prometheus.exc.PrometheusSecurityException;
import io.github.tonywasher.joceanus.prometheus.security.PrometheusSecurityPasswordManager;
import io.github.tonywasher.joceanus.tethys.api.thread.TethysUIThreadStatusReport;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.SAXException;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Iterator;

/**
 * Formatter/Parser class for DataValues.
 */
public class PrometheusDataValuesFormatter {
    /**
     * Entry suffix.
     */
    private static final String SUFFIX_ENTRY = ".xml";

    /**
     * Error text.
     */
    private static final String ERROR_BACKUP = "Failed to create backup XML";

    /**
     * The report.
     */
    private final TethysUIThreadStatusReport theReport;

    /**
     * The password manager.
     */
    private final PrometheusSecurityPasswordManager thePasswordMgr;

    /**
     * The document builder.
     */
    private final DocumentBuilder theBuilder;

    /**
     * The transformer.
     */
    private final Transformer theXformer;

    /**
     * The Data version.
     */
    private Integer theVersion;

    /**
     * Constructor.
     *
     * @param pReport      the report
     * @param pPasswordMgr the password manager
     * @throws PrometheusIOException on error
     */
    public PrometheusDataValuesFormatter(final TethysUIThreadStatusReport pReport,
                                         final PrometheusSecurityPasswordManager pPasswordMgr) throws PrometheusIOException {
        /* Store values */
        theReport = pReport;
        thePasswordMgr = pPasswordMgr;

        /* protect against exceptions */
        try {
            /* Create a Document builder */
            final DocumentBuilderFactory myFactory = DocumentBuilderFactory.newInstance();
            myFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
            myFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            myFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
            theBuilder = myFactory.newDocumentBuilder();

            /* Create the transformer */
            final TransformerFactory myXformFactory = TransformerFactory.newInstance();
            myXformFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
            myXformFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            myXformFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
            theXformer = myXformFactory.newTransformer();

        } catch (ParserConfigurationException | TransformerConfigurationException e) {
            throw new PrometheusIOException("Failed to initialise parser", e);
        }
    }

    /**
     * Create a Backup ZipFile.
     *
     * @param pData Data to write out
     * @param pFile the backup file to write to
     * @throws OceanusException on error
     */
    public void createBackup(final PrometheusDataSet pData,
                             final File pFile) throws OceanusException {
        boolean writeFailed = false;
        try {
            createBackup(pData, new FileOutputStream(pFile));
        } catch (IOException
                 | OceanusException e) {
            writeFailed = true;
            throw new PrometheusIOException(ERROR_BACKUP, e);
        } finally {
            /* Try to delete the file if required */
            if (writeFailed) {
                MetisToolkit.cleanUpFile(pFile);
            }
        }
    }

    /**
     * Create a Backup ZipFile.
     *
     * @param pData      Data to write out
     * @param pZipStream the output stream
     * @throws OceanusException on error
     */
    public void createBackup(final PrometheusDataSet pData,
                             final OutputStream pZipStream) throws OceanusException {
        /* Obtain the active profile */
        final OceanusProfile myTask = theReport.getActiveTask();
        final OceanusProfile myStage = myTask.startTask("Writing");

        /* Protect agains exceptions */
        try {
            /* Create a similar security control */
            final PrometheusSecurityPasswordManager myPasswordMgr = pData.getPasswordMgr();
            final GordianFactoryLock myBase = pData.getFactoryLock();
            final GordianFactoryLock myLock = myPasswordMgr.similarFactoryLock(myBase);
            final GordianZipFactory myZips = myPasswordMgr.getSecurityFactory().getZipFactory();
            final GordianZipLock myZipLock = myZips.zipLock(myLock);

            /* Access the data version */
            theVersion = pData.getControl().getDataVersion();

            /* Declare the number of stages */
            theReport.setNumStages(pData.getListMap().size());

            /* Protect the workbook access */
            try (GordianZipWriteFile myZipFile = myZips.createZipFile(myZipLock, pZipStream)) {
                /* Loop through the data lists */
                final Iterator<PrometheusDataList<?>> myIterator = pData.iterator();
                while (myIterator.hasNext()) {
                    final PrometheusDataList<?> myList = myIterator.next();

                    /* Declare the new stage */
                    theReport.setNewStage(myList.listName());

                    /* If this list should be written */
                    if (myList.includeDataXML()) {
                        /* Write the list details */
                        myStage.startTask(myList.listName());
                        writeXMLListToFile(myList, myZipFile, true);
                    }
                }

                /* Complete the task */
                myStage.end();

            } catch (IOException
                     | OceanusException e) {
                throw new PrometheusIOException(ERROR_BACKUP, e);
            }
        } catch (GordianException e) {
            throw new PrometheusSecurityException(e);
        }
    }

    /**
     * Create a Backup ZipFile.
     *
     * @param pData Data to write out
     * @param pFile the backup file to write to
     * @throws OceanusException on error
     */
    public void createExtract(final PrometheusDataSet pData,
                              final File pFile) throws OceanusException {
        boolean writeFailed = false;
        try {
            createExtract(pData, new FileOutputStream(pFile));
        } catch (IOException
                 | OceanusException e) {
            writeFailed = true;
            throw new PrometheusIOException(ERROR_BACKUP, e);
        } finally {
            /* Try to delete the file if required */
            if (writeFailed) {
                MetisToolkit.cleanUpFile(pFile);
            }
        }
    }

    /**
     * Create an Extract ZipFile.
     *
     * @param pData      Data to write out
     * @param pZipStream the output stream
     * @throws OceanusException on error
     */
    public void createExtract(final PrometheusDataSet pData,
                              final OutputStream pZipStream) throws OceanusException {
        /* Obtain the active profile */
        final OceanusProfile myTask = theReport.getActiveTask();
        final OceanusProfile myStage = myTask.startTask("Writing");

        /* Access the data version */
        theVersion = pData.getControl().getDataVersion();

        /* Declare the number of stages */
        theReport.setNumStages(pData.getListMap().size());
        final GordianZipFactory myZips = thePasswordMgr.getSecurityFactory().getZipFactory();

        /* Protect the workbook access */
        try (GordianZipWriteFile myZipFile = myZips.createZipFile(pZipStream)) {
            /* Loop through the data lists */
            final Iterator<PrometheusDataList<?>> myIterator = pData.iterator();
            while (myIterator.hasNext()) {
                final PrometheusDataList<?> myList = myIterator.next();

                /* Declare the new stage */
                theReport.setNewStage(myList.listName());

                /* If this list should be written */
                if (myList.includeDataXML()) {
                    /* Write the list details */
                    myStage.startTask(myList.listName());
                    writeXMLListToFile(myList, myZipFile, false);
                }
            }

            /* Complete the task */
            myStage.end();

        } catch (IOException
                 | OceanusException e) {
            throw new PrometheusIOException("Failed to create extract XML", e);
        }
    }

    /**
     * Write XML list to file.
     *
     * @param pList     the data list
     * @param pZipFile  the output zipFile
     * @param pStoreIds do we include IDs in XML
     * @throws OceanusException on error
     */
    private void writeXMLListToFile(final PrometheusDataList<?> pList,
                                    final GordianZipWriteFile pZipFile,
                                    final boolean pStoreIds) throws OceanusException {
        /* Access the list name */
        final String myName = pList.listName() + SUFFIX_ENTRY;

        /* Protect the workbook access */
        try (OutputStream myStream = pZipFile.createOutputStream(new File(myName), true)) {
            /* Create a new document */
            final Document myDocument = theBuilder.newDocument();

            /* Populate the document from the list */
            populateXML(myDocument, pList, pStoreIds);

            /* Format the XML and write to stream */
            theXformer.transform(new DOMSource(myDocument), new StreamResult(myStream));

        } catch (GordianException
                 | TransformerException
                 | IOException e) {
            throw new PrometheusIOException("Failed to transform XML", e);
        }
    }

    /**
     * Create XML for a list.
     *
     * @param pDocument the document to hold the list.
     * @param pList     the data list
     * @param pStoreIds do we include IDs in XML
     * @throws OceanusException on error
     */
    private void populateXML(final Document pDocument,
                             final PrometheusDataList<?> pList,
                             final boolean pStoreIds) throws OceanusException {
        /* Create an element for the item */
        final Element myElement = pDocument.createElement(pList.listName());
        pDocument.appendChild(myElement);

        /* Access the Data formatter */
        final OceanusDataFormatter myFormatter = pList.getDataSet().getDataFormatter();

        /* Declare the number of steps */
        final int myTotal = pList.size();
        theReport.setNumSteps(myTotal);

        /* Set the list type and size */
        myElement.setAttribute(PrometheusDataValues.ATTR_TYPE, pList.getItemType().getItemName());
        myElement.setAttribute(PrometheusDataValues.ATTR_SIZE, Integer.toString(myTotal));
        myElement.setAttribute(PrometheusDataValues.ATTR_VERS, Integer.toString(theVersion));

        /* Iterate through the list */
        final Iterator<?> myIterator = pList.iterator();
        while (myIterator.hasNext()) {
            final Object myObject = myIterator.next();

            /* Ignore if not a DataItem */
            if (!(myObject instanceof PrometheusDataItem myItem)) {
                continue;
            }

            /* Skip over child items */
            if (myItem instanceof PrometheusGroupedItem myGrouped
                    && myGrouped.isChild()) {
                continue;
            }

            /* Create DataValues for item */
            final PrometheusDataValues myValues = new PrometheusDataValues(myItem);

            /* Add the child to the list */
            final Element myChild = myValues.createXML(pDocument, myFormatter, pStoreIds);
            myElement.appendChild(myChild);

            /* Report the progress */
            theReport.setNextStep();
        }
    }

    /**
     * Load a ZipFile.
     *
     * @param pData DataSet to load into
     * @param pFile the file to load
     * @throws OceanusException on error
     */
    public void loadZipFile(final PrometheusDataSet pData,
                            final File pFile) throws OceanusException {
        try {
            loadZipFile(pData, new FileInputStream(pFile), pFile.getName());
        } catch (IOException e) {
            throw new PrometheusIOException("Failed to access ZipFile", e);
        }
    }

    /**
     * Load a ZipFile.
     *
     * @param pData     DataSet to load into
     * @param pInStream the input stream
     * @param pName     the file to load
     * @throws OceanusException on error
     */
    public void loadZipFile(final PrometheusDataSet pData,
                            final InputStream pInStream,
                            final String pName) throws OceanusException {
        /* Protect against exceptions */
        try {
            /* Obtain the active profile */
            final OceanusProfile myTask = theReport.getActiveTask();
            final OceanusProfile myStage = myTask.startTask("Loading");
            myStage.startTask("Parsing");

            /* Access the zip file */
            final GordianZipFactory myZips = thePasswordMgr.getSecurityFactory().getZipFactory();
            final GordianZipReadFile myZipFile = myZips.openZipFile(pInStream);

            /* Obtain the hash bytes from the file */
            final GordianZipLock myLock = myZipFile.getLock();

            /* If this is a secure ZipFile */
            if (myLock != null) {
                /* Resolve the lock */
                thePasswordMgr.resolveZipLock(myLock, pName);
            }

            /* Parse the Zip File */
            parseZipFile(myStage, pData, myZipFile);

            /* Complete the task */
            myStage.end();

        } catch (GordianException e) {
            throw new PrometheusSecurityException(e);
        }
    }

    /**
     * Parse a ZipFile.
     *
     * @param pProfile the active profile
     * @param pData    DataSet to load into
     * @param pZipFile the file to parse
     * @throws OceanusException on error
     */
    private void parseZipFile(final OceanusProfile pProfile,
                              final PrometheusDataSet pData,
                              final GordianZipReadFile pZipFile) throws OceanusException {
        /* Start new stage */
        final OceanusProfile myStage = pProfile.startTask("Loading");

        /* Declare the number of stages */
        theReport.setNumStages(pData.getListMap().size());

        /* Loop through the data lists */
        final Iterator<PrometheusDataList<?>> myIterator = pData.iterator();
        while (myIterator.hasNext()) {
            final PrometheusDataList<?> myList = myIterator.next();

            /* Declare the new stage */
            theReport.setNewStage(myList.listName());

            /* If this list should be read */
            if (myList.includeDataXML()) {
                /* Write the list details */
                myStage.startTask(myList.listName());
                readXMLListFromFile(myList, pZipFile);
            }

            /* postProcessList after load */
            myList.postProcessOnLoad();
        }

        /* Create the control data */
        pData.getControlData().addNewControl(theVersion);

        /* Complete the task */
        myStage.end();
    }

    /**
     * Read XML list from file.
     *
     * @param pList    the data list
     * @param pZipFile the input zipFile
     * @throws OceanusException on error
     */
    private void readXMLListFromFile(final PrometheusDataList<?> pList,
                                     final GordianZipReadFile pZipFile) throws OceanusException {
        /* Protect against exceptions */
        try {
            /* Access the list name */
            final String myName = pList.listName() + SUFFIX_ENTRY;

            /* Locate the correct entry */
            final GordianZipFileContents myContents = pZipFile.getContents();
            final GordianZipFileEntry myEntry = myContents.findFileEntry(myName);
            if (myEntry == null) {
                throw new PrometheusDataException("List not found " + myName);
            }

            /* Protect the workbook access */
            try (InputStream myStream = pZipFile.createInputStream(myEntry)) {
                /* Read the document from the stream and parse it */
                final Document myDocument = theBuilder.parse(myStream);

                /* Populate the list from the document */
                parseXMLDocument(myDocument, pList);

            } catch (IOException
                     | SAXException e) {
                throw new PrometheusIOException("Failed to parse XML", e);
            }
        } catch (GordianException e) {
            throw new PrometheusSecurityException(e);
        }
    }

    /**
     * parse an XML document into DataValues.
     *
     * @param pDocument the document that holds the list.
     * @param pList     the data list
     * @throws OceanusException on error
     */
    private void parseXMLDocument(final Document pDocument,
                                  final PrometheusDataList<?> pList) throws OceanusException {
        /* Access the parent element */
        final Element myElement = pDocument.getDocumentElement();
        final MetisListKey myItemType = pList.getItemType();

        /* Check that the document name and dataType are correct */
        if (!MetisDataDifference.isEqual(myElement.getNodeName(), pList.listName())
                || !MetisDataDifference.isEqual(myElement.getAttribute(PrometheusDataValues.ATTR_TYPE), myItemType.getItemName())) {
            throw new PrometheusDataException("Invalid list type");
        }

        /* If this is the first Data version */
        final Integer myVersion = Integer.valueOf(myElement.getAttribute(PrometheusDataValues.ATTR_VERS));
        if (theVersion == null) {
            theVersion = myVersion;
        } else if (!theVersion.equals(myVersion)) {
            throw new PrometheusDataException("Inconsistent data version");
        }

        /* Access field types for list */
        final MetisFieldSetDef myFields = pList.getItemFields();

        /* Access the Data formatter */
        final OceanusDataFormatter myFormatter = pList.getDataSet().getDataFormatter();

        /* Declare the number of steps */
        final int myTotal = getListCount(myFormatter, myElement);
        theReport.setNumSteps(myTotal);

        /* Loop through the children */
        for (Node myChild = myElement.getFirstChild(); myChild != null; myChild = myChild.getNextSibling()) {
            /* Ignore non-elements */
            if (!(myChild instanceof Element)) {
                continue;
            }

            /* Access as Element */
            final Element myItem = (Element) myChild;

            /* Create DataArguments for item */
            final PrometheusDataValues myValues = new PrometheusDataValues(myItem, myFields);

            /* Add the child to the list */
            pList.addValuesItem(myValues);

            /* Report the progress */
            theReport.setNextStep();
        }
    }

    /**
     * Obtain count attribute.
     *
     * @param pFormatter the formatter.
     * @param pElement   the element that holds the count.
     * @return the list count
     * @throws OceanusException on error
     */
    private static Integer getListCount(final OceanusDataFormatter pFormatter,
                                        final Element pElement) throws OceanusException {
        try {
            /* Access the list count */
            final String mySize = pElement.getAttribute(PrometheusDataValues.ATTR_SIZE);
            return pFormatter.parseValue(mySize, Integer.class);
        } catch (NumberFormatException e) {
            throw new PrometheusDataException("Invalid list count", e);
        }
    }
}