/*
 * 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.jackrabbit.vault.fs.io;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.time.ZoneOffset;
import java.util.Calendar;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Properties;
import java.util.Set;
import java.util.TimeZone;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;

import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.nodetype.NodeDefinition;
import javax.jcr.nodetype.NodeType;
import javax.jcr.nodetype.NodeTypeManager;

import org.apache.jackrabbit.util.ISO8601;
import org.apache.jackrabbit.vault.fs.api.AggregateManager;
import org.apache.jackrabbit.vault.fs.api.PathFilterSet;
import org.apache.jackrabbit.vault.fs.api.ProgressTrackerListener;
import org.apache.jackrabbit.vault.fs.api.SimplePathMapping;
import org.apache.jackrabbit.vault.fs.api.VaultFile;
import org.apache.jackrabbit.vault.fs.api.WorkspaceFilter;
import org.apache.jackrabbit.vault.fs.config.MetaInf;
import org.apache.jackrabbit.vault.fs.spi.CNDWriter;
import org.apache.jackrabbit.vault.fs.spi.ProgressTracker;
import org.apache.jackrabbit.vault.fs.spi.ServiceProviderFactory;
import org.apache.jackrabbit.vault.packaging.PackageId;
import org.apache.jackrabbit.vault.packaging.PackageType;
import org.apache.jackrabbit.vault.util.Constants;
import org.apache.jackrabbit.vault.util.PlatformNameFormat;
import org.apache.jackrabbit.util.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.apache.jackrabbit.vault.packaging.PackageProperties.NAME_DEPENDENCIES;
import static org.apache.jackrabbit.vault.packaging.PackageProperties.NAME_DESCRIPTION;
import static org.apache.jackrabbit.vault.packaging.PackageProperties.NAME_GROUP;
import static org.apache.jackrabbit.vault.packaging.PackageProperties.NAME_NAME;
import static org.apache.jackrabbit.vault.packaging.PackageProperties.NAME_PACKAGE_TYPE;
import static org.apache.jackrabbit.vault.packaging.PackageProperties.NAME_VERSION;

/**
 * Generic context for exporters
 *
 */
public abstract class AbstractExporter implements AutoCloseable {

    /**
     * default logger
     */
    private static final Logger log = LoggerFactory.getLogger(AbstractExporter.class);

    /**
     * name of the manifest property for the package id
     */
    private static final String MF_PACKAGE_ID = "Content-Package-Id";

    /**
     * name of the manifest property for the package dependencies
     */
    private static final String MF_PACKAGE_DEPENDENCIES = "Content-Package-Dependencies";

    /**
     * name of the manifest property for the package roots
     */
    private static final String MF_PACKAGE_ROOTS = "Content-Package-Roots";

    /**
     * name of the manifest property for the package description
     */
    private static final String MF_PACKAGE_DESC = "Content-Package-Description";

    /**
     * name of the manifest property for the package type
     */
    private static final String MF_PACKAGE_TYPE = "Content-Package-Type";

    private ProgressTracker tracker;

    private boolean relativePaths;

    private String rootPath = Constants.ROOT_DIR;

    private Properties properties = new Properties();

    private boolean noMetaInf;

    protected ExportInfo exportInfo = new ExportInfo();

    private static String DEFAULT_GENERATOR = "org.apache.jackrabbit.vault" + ":"
            + getVersion("org.apache.jackrabbit.vault", AbstractExporter.class);

    private String generator = DEFAULT_GENERATOR;

    public boolean isVerbose() {
        return tracker != null;
    }

    public void setVerbose(ProgressTrackerListener out) {
        if (out == null) {
            tracker = null;
        } else {
            if (tracker == null) {
                tracker = new ProgressTracker();
            }
            tracker.setListener(out);
        }
    }

    public boolean isRelativePaths() {
        return relativePaths;
    }

    public void setProperty(String name, String value) {
        properties.put(name, value);
    }

    public void setProperty(String name, Calendar value) {
        properties.put(name, ISO8601.format(value));
    }

    public void setProperties(Properties properties) {
        if (properties != null) {
            this.properties.putAll(properties);
        }
    }

    /**
     * @return generator information (for diagnostical purposes; usually in
     *         "name:version" format). {@code null} when n/a. Defaults to
     *         "org.apache.jackrabbit.vault:version".
     */
    public String getGenerator() {
        return generator;
    }

    /**
     * @param generator
     *            generator information (for diagnostical purposes; usually in
     *            "name:version" format). {@code null} when n/a.
     */
    public void setGenerator(String generator) {
        this.generator = generator;
    }

    public String getRootPath() {
        return rootPath;
    }

    public void setRootPath(String rootPath) {
        this.rootPath = rootPath;
    }

    public boolean isNoMetaInf() {
        return noMetaInf;
    }

    public void setNoMetaInf(boolean noMetaInf) {
        this.noMetaInf = noMetaInf;
    }

    public ExportInfo getExportInfo() {
        return exportInfo;
    }

    /**
     * Defines if the exported files should include their entire path or just
     * be relative to the export root. eg.: exporting /apps/components relative
     * would not include /apps in the path.
     *
     * @param relativePaths relative flag
     */
    public void setRelativePaths(boolean relativePaths) {
        this.relativePaths = relativePaths;
    }

    /**
     * Exports the given vault file and writes the META-INF data.
     * @param parent the vault file
     * @throws RepositoryException if an error occurs
     * @throws IOException if an I/O error occurs
     */
    public void export(VaultFile parent) throws RepositoryException, IOException {
        export(parent, false);
    }

    /**
     * Exports the given vault file and writes the META-INF data.
     * @param parent the vault file
     * @param noClose if {@code true} exporter will not be closed after export
     * @throws RepositoryException if an error occurs
     * @throws IOException if an I/O error occurs
     */
    public void export(VaultFile parent, boolean noClose)
            throws RepositoryException, IOException {
        exportInfo.getEntries().clear();
        open();
        AggregateManager mgr = parent.getFileSystem().getAggregateManager();
        mgr.startTracking(tracker == null ? null : tracker.getListener());
        if (!noMetaInf) {
            // update properties
            setProperty(MetaInf.CREATED, Calendar.getInstance(TimeZone.getTimeZone(ZoneOffset.UTC), Locale.ROOT));
            setProperty(MetaInf.CREATED_BY, mgr.getUserId());
            setProperty(MetaInf.GENERATOR, generator);
            setProperty(MetaInf.PACKAGE_FORMAT_VERSION, String.valueOf(MetaInf.FORMAT_VERSION_2));

            // get filter and translate if necessary
            WorkspaceFilter filter = mgr.getWorkspaceFilter();
            String mountPath = mgr.getRoot().getPath();
            String rootPath = parent.getPath();
            if ("/".equals(rootPath)) {
                rootPath = "";
            }
            if (mountPath.length() > 0 || rootPath.length() > 0) {
                filter = filter.translate(new SimplePathMapping(mountPath, PlatformNameFormat.getRepositoryPath(rootPath)));
            }

            // check for package type
            if (!properties.containsKey(NAME_PACKAGE_TYPE)) {
                properties.setProperty(NAME_PACKAGE_TYPE, detectPackageType(filter).name().toLowerCase(Locale.ROOT));
            }

            // write Manifest
            Manifest mf = new Manifest();
            mf.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
            String version = properties.getProperty(NAME_VERSION);
            if (version == null) {
                version = "";
            }
            String group = properties.getProperty(NAME_GROUP);
            String name = properties.getProperty(NAME_NAME);
            PackageId id = new PackageId(group, name, version);
            Set<String> rts = new HashSet<String>();
            for (PathFilterSet p: filter.getFilterSets()) {
                rts.add(p.getRoot());
            }
            String[] filterRoots = rts.toArray(new String[rts.size()]);
            addManifestAttribute(mf, MF_PACKAGE_ID, id.toString());
            addManifestAttribute(mf, MF_PACKAGE_DESC, properties.getProperty(NAME_DESCRIPTION));
            addManifestAttribute(mf, MF_PACKAGE_ROOTS, Text.implode(filterRoots, ","));
            addManifestAttribute(mf, MF_PACKAGE_DEPENDENCIES, properties.getProperty(NAME_DEPENDENCIES));
            addManifestAttribute(mf, MF_PACKAGE_TYPE, properties.getProperty(NAME_PACKAGE_TYPE));
            ByteArrayOutputStream tmpOut = new ByteArrayOutputStream();
            mf.write(tmpOut);
            writeFile(new ByteArrayInputStream(tmpOut.toByteArray()), JarFile.MANIFEST_NAME);

            createDirectory(Constants.META_INF);
            createDirectory(Constants.META_DIR);
            // add some 'fake' tracking
            track("A", Constants.META_INF);
            track("A", JarFile.MANIFEST_NAME);
            track("A", Constants.META_DIR);
            track("A", Constants.META_DIR + "/" + Constants.CONFIG_XML);
            track("A", Constants.META_DIR + "/" + Constants.FILTER_XML);
            track("A", Constants.META_DIR + "/" + Constants.NODETYPES_CND);
            track("A", Constants.META_DIR + "/" + Constants.PROPERTIES_XML);

            // write properties
            tmpOut = new ByteArrayOutputStream();
            properties.storeToXML(tmpOut, "FileVault Package Properties", "utf-8");
            writeFile(new ByteArrayInputStream(tmpOut.toByteArray()), Constants.META_DIR + "/" + Constants.PROPERTIES_XML);
            writeFile(mgr.getConfig().getSource(), Constants.META_DIR + "/" + Constants.CONFIG_XML);
            writeFile(filter.getSource(), Constants.META_DIR + "/" + Constants.FILTER_XML);
        }
        export(parent, "");
        if (!noMetaInf) {
            // write node types last, as they are calculated during export.
            writeFile(getNodeTypes(mgr.getSession(), mgr.getNodeTypes()), Constants.META_DIR + "/" + Constants.NODETYPES_CND);
        }
        if (!noClose) {
            close();
        }
        mgr.stopTracking();
    }

    /**
     * Exports the vault file to the relative path.
     * @param parent the file
     * @param relPath the path
     * @throws RepositoryException if an error occurs
     * @throws IOException if an I/O error occurs
     */
    public void export(VaultFile parent, String relPath)
            throws RepositoryException, IOException {
        for (VaultFile vaultFile : parent.getChildren()) {
            String path = relPath + "/" + vaultFile.getName();
            if (vaultFile.isDirectory()) {
                createDirectory(vaultFile, path);
                export(vaultFile, path);
            } else {
                writeFile(vaultFile, path);
            }
        }
    }

    protected void track(String action, String path) {
        if ("E".equals(action)) {
            log.error("{} {}", action, path);
        } else {
            log.debug("{} {}", action, path);
        }
        if (tracker != null) {
            tracker.track(action, path);
        }
    }

    protected void track(Exception e, String path) {
        log.error("E {} ({})", path, e.toString());
        if (tracker != null) {
            tracker.track(e, path);
        }
    }
    
    protected String getPlatformFilePath(VaultFile file, String relPath) {
        StringBuilder buf = new StringBuilder(rootPath);
        if (isRelativePaths()) {
            // relative paths are only needed for special exports, like the definition export of packaging
            if (buf.length() > 0) {
                buf.append("/");
            }
            buf.append(relPath);
        } else {
            buf.append(file.getPath());
        }
        return buf.toString();
    }
    
    private InputStream getNodeTypes(Session s, Collection<String> nodeTypes)
            throws IOException, RepositoryException {
        NodeTypeManager ntMgr = s.getWorkspace().getNodeTypeManager();
        // init with repository predefined node types
        Set<String> written = new HashSet<String>();
        written.addAll(ServiceProviderFactory.getProvider().getBuiltInNodeTypeNames());
        StringWriter out = new StringWriter();
        CNDWriter w = ServiceProviderFactory.getProvider().getCNDWriter(out, s, true);
        for (String nt: nodeTypes) {
            writeNodeType(ntMgr.getNodeType(nt), w, written);
        }
        w.close();
        return new ByteArrayInputStream(out.getBuffer().toString().getBytes("utf-8"));
    }

    private void writeNodeType(NodeType nt, CNDWriter w, Set<String> written)
            throws IOException, RepositoryException {
        if (nt != null && !written.contains(nt.getName())) {
            written.add(nt.getName());
            w.write(nt);
            for (NodeType s: nt.getSupertypes()) {
                writeNodeType(s, w, written);
            }
            for (NodeDefinition n: nt.getChildNodeDefinitions()) {
                writeNodeType(n.getDefaultPrimaryType(), w, written);
                if (n.getRequiredPrimaryTypes() != null) {
                    for (NodeType r: n.getRequiredPrimaryTypes()) {
                        writeNodeType(r, w, written);
                    }
                }
            }
        }
    }

    /**
     * Adds a new attribute to the given manifest.
     * @param manifest the manifest
     * @param key attribute name
     * @param value attribute value
     */
    private static void addManifestAttribute(Manifest manifest, String key, String value) {
        if (value != null && value.length() > 0) {
            Attributes.Name name = new Attributes.Name(key);
            manifest.getMainAttributes().put(name, value);
        }
    }

    /**
     * Detects the package type based on the workspace filter.
     * @param filter the workspace filter
     * @return the package type
     */
    private static PackageType detectPackageType(WorkspaceFilter filter)  {
        boolean hasApps = false;
        boolean hasOther = false;
        for (PathFilterSet p: filter.getFilterSets()) {
            if ("cleanup".equals(p.getType())) {
                continue;
            }
            String root = p.getRoot();
            if ("/apps".equals(root) || root.startsWith("/apps/") || "/libs".equals(root) || root.startsWith("/libs/")) {
                hasApps = true;
            } else {
                hasOther = true;
            }
        }
        if (hasApps && !hasOther) {
            return PackageType.APPLICATION;
        } else if (hasOther && !hasApps) {
            return PackageType.CONTENT;
        }
        return PackageType.MIXED;
    }

    /**
     * Returns the version of an Oak module.
     *
     * @param moduleName the name of the module
     * @param clazz a class of the module
     * @return the version (or "SNAPSHOT" when unknown)
     */
    private static String getVersion(String moduleName, Class<?> clazz) {
        // borrowed from oak-commons
        String version = "SNAPSHOT"; // fallback
        try (InputStream stream = clazz
                .getResourceAsStream("/META-INF/maven/org.apache.jackrabbit.vault/" + moduleName + "/pom.properties")) {
            if (stream != null) {
                Properties properties = new Properties();
                properties.load(stream);
                return properties.getProperty("version", version);
            }
        } catch (IOException ignored) {
            log.warn("failed to read pom properties for " + moduleName, ignored);
        }
        return version;
    }

    /**
     * Opens the exporter and initializes the undelying structures.
     * @throws IOException if an I/O error occurs
     * @throws RepositoryException if a repository error occurs
     */
    public abstract void open() throws IOException, RepositoryException;

    /**
     * Closes the exporter and releases the undelying structures.
     * @throws IOException if an I/O error occurs
     * @throws RepositoryException if a repository error occurs
     */
    public abstract void close() throws IOException, RepositoryException;

    public abstract void createDirectory(String relPath)
            throws IOException;

    public abstract void createDirectory(VaultFile file, String relPath)
            throws RepositoryException, IOException;

    /**
     * <p>The specified stream is automatically closed after this method returns or throws an exception.
     * @param in
     * @param relPath
     * @throws IOException
     */
    public abstract void writeFile(InputStream in, String relPath)
            throws IOException;

    public abstract void writeFile(VaultFile file, String relPath)
            throws RepositoryException, IOException;

}