/*****************************************************************
 *   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
 *
 *    https://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.cayenne.project.upgrade;

import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import javax.xml.transform.Result;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;

import org.apache.cayenne.ConfigurationException;
import org.apache.cayenne.configuration.ConfigurationTree;
import org.apache.cayenne.configuration.DataChannelDescriptor;
import org.apache.cayenne.configuration.DataChannelDescriptorLoader;
import org.apache.cayenne.di.Inject;
import org.apache.cayenne.map.DataMap;
import org.apache.cayenne.map.EntityResolver;
import org.apache.cayenne.project.Project;
import org.apache.cayenne.project.ProjectSaver;
import org.apache.cayenne.project.upgrade.handlers.UpgradeHandler;
import org.apache.cayenne.resource.Resource;
import org.apache.cayenne.util.Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;

import static org.apache.cayenne.util.Util.isBlank;

/**
 *
 * Upgrade service sequence is following:
 * 1. This cycle should be done by Modeler and will result in a full project upgrade
 *
 *  - find all project and datamap resources
 *  - define set of upgrade handlers to process those resources
 *  - process DOM (project + N data maps)
 *  - save & load cycle to flush all DOM changes
 *  - process project model
 *  - save once again to cleanup and sort final XML
 *
 * 2. This cycle can be used by ServerRuntime to optionally support old project versions
 *
 *  - find all project and datamap resources
 *  - define set of upgrade handlers to process those resources
 *  - process DOM (project + N data maps)
 *  - directly load model from DOM w/o saving
 *  - process project model
 *
 * @since 4.1
 */
public class DefaultUpgradeService implements UpgradeService {

    private static final Logger logger = LoggerFactory.getLogger(DefaultUpgradeService.class);

    public static final String UNKNOWN_VERSION = "0";
    public static final String MIN_SUPPORTED_VERSION = "6";

    TreeMap<String, UpgradeHandler> handlers = new TreeMap<>(VersionComparator.INSTANCE);

    @Inject
    private ProjectSaver projectSaver;

    @Inject
    private DataChannelDescriptorLoader loader;

    public DefaultUpgradeService(@Inject List<UpgradeHandler> handlerList) {
        for(UpgradeHandler handler : handlerList) {
            handlers.put(handler.getVersion(), handler);
        }
    }

    @Override
    public UpgradeMetaData getUpgradeType(Resource resource) {
        UpgradeMetaData metaData = new UpgradeMetaData();

        String version = loadProjectVersion(resource);
        metaData.setProjectVersion(version);
        metaData.setSupportedVersion(String.valueOf(Project.VERSION));

        int c1 = VersionComparator.INSTANCE.compare(version, MIN_SUPPORTED_VERSION);
        if (c1 < 0) {
            metaData.setIntermediateUpgradeVersion(MIN_SUPPORTED_VERSION);
            metaData.setUpgradeType(UpgradeType.INTERMEDIATE_UPGRADE_NEEDED);
            return metaData;
        }

        int c2 = VersionComparator.INSTANCE.compare(String.valueOf(Project.VERSION), version);
        if (c2 < 0) {
            metaData.setUpgradeType(UpgradeType.DOWNGRADE_NEEDED);
        } else if (c2 == 0) {
            metaData.setUpgradeType(UpgradeType.UPGRADE_NOT_NEEDED);
        } else {
            metaData.setUpgradeType(UpgradeType.UPGRADE_NEEDED);
        }
        return metaData;
    }

    protected List<UpgradeHandler> getHandlersForVersion(String version) {
        boolean found = MIN_SUPPORTED_VERSION.equals(version);
        List<UpgradeHandler> handlerList = new ArrayList<>();

        for(Map.Entry<String, UpgradeHandler> entry : handlers.entrySet()) {
            if(entry.getKey().equals(version)) {
                found = true;
                continue;
            }
            if(!found) {
                continue;
            }

            handlerList.add(entry.getValue());
        }

        return handlerList;
    }

    @Override
    public Resource upgradeProject(Resource resource) {
        List<UpgradeHandler> handlerList = getHandlersForVersion(loadProjectVersion(resource));

        List<UpgradeUnit> upgradeUnits = upgradeDOM(resource, handlerList);
        saveDOM(upgradeUnits);

        resource = upgradeUnits.get(0).getResource();

        ConfigurationTree<DataChannelDescriptor> configurationTree = upgradeModel(resource, handlerList);
        saveModel(configurationTree);

        return resource;
    }

    protected List<UpgradeUnit> upgradeDOM(Resource resource, List<UpgradeHandler> handlerList) {
        List<UpgradeUnit> allUnits = new ArrayList<>();

        // Load DOM for all resources
        Document projectDocument = Util.readDocument(resource.getURL());
        UpgradeUnit projectUnit = new UpgradeUnit(resource, projectDocument);
        allUnits.add(projectUnit);

        List<Resource> dataMapResources = getAdditionalDatamapResources(projectUnit);
        List<UpgradeUnit> dataMapUnits = new ArrayList<>(dataMapResources.size());
        for (Resource dataMapResource : dataMapResources) {
            dataMapUnits.add(new UpgradeUnit(dataMapResource, Util.readDocument(dataMapResource.getURL())));
        }
        allUnits.addAll(dataMapUnits);

        // Update DOM
        for(UpgradeHandler handler : handlerList) {
            handler.processProjectDom(projectUnit);
            for(UpgradeUnit dataMapUnit : dataMapUnits) {
                handler.processDataMapDom(dataMapUnit);
            }
        }

        return allUnits;
    }

    protected void saveDOM(Collection<UpgradeUnit> upgradeUnits) {
        for(UpgradeUnit unit : upgradeUnits) {
            saveDocument(unit);
        }
    }

    protected ConfigurationTree<DataChannelDescriptor> upgradeModel(Resource resource, List<UpgradeHandler> handlerList) {
        // Load Model back from the update XML
        ConfigurationTree<DataChannelDescriptor> configurationTree = loadProject(resource);

        // Update model level if needed
        for(UpgradeHandler handler : handlerList) {
            handler.processModel(configurationTree.getRootNode());
        }

        return configurationTree;
    }

    protected ConfigurationTree<DataChannelDescriptor> loadProject(Resource resource) {
        // Load Model back from the update XML
        ConfigurationTree<DataChannelDescriptor> configurationTree = loader.load(resource);

        // link all datamaps, or else we will lose cross-datamaps relationships
        EntityResolver resolver = new EntityResolver();
        for(DataMap dataMap : configurationTree.getRootNode().getDataMaps()) {
            resolver.addDataMap(dataMap);
            dataMap.setNamespace(resolver);
        }
        return configurationTree;
    }

    protected void saveModel(ConfigurationTree<DataChannelDescriptor> configurationTree) {
        // Save project once again via project saver, this will normalize XML to minimize final diff
        Project project = new Project(configurationTree);
        projectSaver.save(project);
    }

    List<Resource> getAdditionalDatamapResources(UpgradeUnit upgradeUnit) {
        List<Resource> resources = new ArrayList<>();
        try {
            XPath xpath = XPathFactory.newInstance().newXPath();
            NodeList nodes = (NodeList) xpath.evaluate("/domain/map/@name", upgradeUnit.getDocument(), XPathConstants.NODESET);
            for (int i = 0; i < nodes.getLength(); i++) {
                Node mapNode = nodes.item(i);
                // in version 3.0.0.1 and earlier map tag had attribute location,
                // but it was always equal to data map name + ".map.xml"
                Resource mapResource = upgradeUnit.getResource().getRelativeResource(mapNode.getNodeValue() + ".map.xml");
                resources.add(mapResource);
            }
        } catch (Exception ex) {
            logger.warn("Can't get additional dataMap resources: ", ex);
        }
        return resources;
    }

    protected void saveDocument(UpgradeUnit upgradeUnit) {
        try {
            Source input = new DOMSource(upgradeUnit.getDocument());
            Result output = new StreamResult(Util.toFile(upgradeUnit.getResource().getURL()));

            Transformer transformer = TransformerFactory.newInstance().newTransformer();
            transformer.transform(input, output);
        } catch (Exception ex) {
            logger.warn("Can't save the document: ", ex);
        }
    }

    /**
     * A default method for quick extraction of the project version from an XML
     * file.
     */
    protected String loadProjectVersion(Resource resource) {

        RootTagHandler rootHandler = new RootTagHandler();
        URL url = resource.getURL();
        try (InputStream in = url.openStream()) {
            XMLReader parser = Util.createXmlReader();
            parser.setContentHandler(rootHandler);
            parser.setErrorHandler(rootHandler);
            parser.parse(new InputSource(in));
        } catch (SAXException e) {
            // expected... handler will terminate as soon as it finds a root tag.
        } catch (Exception e) {
            throw new ConfigurationException("Error reading configuration from %s", e, url);
        }

        return rootHandler.projectVersion != null ? rootHandler.projectVersion : UNKNOWN_VERSION;
    }

    protected static double decodeVersion(String version) {
        if (version == null || isBlank(version)) {
            return 0;
        }

        // leave the first dot, and treat remaining as a fraction
        // remove all non digit chars
        StringBuilder buffer = new StringBuilder(version.length());
        boolean dotProcessed = false;
        for (int i = 0; i < version.length(); i++) {
            char nextChar = version.charAt(i);
            if (nextChar == '.' && !dotProcessed) {
                dotProcessed = true;
                buffer.append('.');
            } else if (Character.isDigit(nextChar)) {
                buffer.append(nextChar);
            }
        }

        return Double.parseDouble(buffer.toString());
    }

    private static class VersionComparator implements Comparator<String> {

        private static final VersionComparator INSTANCE = new VersionComparator();

        @Override
        public int compare(String o1, String o2) {
            if (o1.equals(o2)) {
                return 0;
            }
            double v1Double = decodeVersion(o1);
            double v2Double = decodeVersion(o2);
            return v1Double < v2Double ? -1 : 1;
        }
    }

    class RootTagHandler extends DefaultHandler {

        private String projectVersion;

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {

            this.projectVersion = attributes.getValue("", "project-version");

            // bail right away - we are not interested in reading this to the end
            throw new SAXException("finished");
        }
    }
}
