/*
 * 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.maven.artifact.repository.metadata;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.maven.artifact.metadata.ArtifactMetadata;
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.artifact.repository.ArtifactRepositoryPolicy;
import org.apache.maven.artifact.repository.DefaultRepositoryRequest;
import org.apache.maven.artifact.repository.RepositoryRequest;
import org.apache.maven.artifact.repository.metadata.io.xpp3.MetadataXpp3Reader;
import org.apache.maven.artifact.repository.metadata.io.xpp3.MetadataXpp3Writer;
import org.apache.maven.repository.legacy.UpdateCheckManager;
import org.apache.maven.repository.legacy.WagonManager;
import org.apache.maven.wagon.ResourceDoesNotExistException;
import org.apache.maven.wagon.TransferFailedException;
import org.codehaus.plexus.component.annotations.Component;
import org.codehaus.plexus.component.annotations.Requirement;
import org.codehaus.plexus.logging.AbstractLogEnabled;
import org.codehaus.plexus.util.ReaderFactory;
import org.codehaus.plexus.util.WriterFactory;
import org.codehaus.plexus.util.xml.pull.XmlPullParserException;

/**
 * @author Jason van Zyl
 */
@Component(role = RepositoryMetadataManager.class)
public class DefaultRepositoryMetadataManager extends AbstractLogEnabled implements RepositoryMetadataManager {
    @Requirement
    private WagonManager wagonManager;

    @Requirement
    private UpdateCheckManager updateCheckManager;

    public void resolve(
            RepositoryMetadata metadata,
            List<ArtifactRepository> remoteRepositories,
            ArtifactRepository localRepository)
            throws RepositoryMetadataResolutionException {
        RepositoryRequest request = new DefaultRepositoryRequest();
        request.setLocalRepository(localRepository);
        request.setRemoteRepositories(remoteRepositories);
        resolve(metadata, request);
    }

    public void resolve(RepositoryMetadata metadata, RepositoryRequest request)
            throws RepositoryMetadataResolutionException {
        ArtifactRepository localRepo = request.getLocalRepository();
        List<ArtifactRepository> remoteRepositories = request.getRemoteRepositories();

        if (!request.isOffline()) {
            Date localCopyLastModified = null;
            if (metadata.getBaseVersion() != null) {
                localCopyLastModified = getLocalCopyLastModified(localRepo, metadata);
            }

            for (ArtifactRepository repository : remoteRepositories) {
                ArtifactRepositoryPolicy policy = metadata.getPolicy(repository);

                File file =
                        new File(localRepo.getBasedir(), localRepo.pathOfLocalRepositoryMetadata(metadata, repository));
                boolean update;

                if (!policy.isEnabled()) {
                    update = false;

                    if (getLogger().isDebugEnabled()) {
                        getLogger()
                                .debug("Skipping update check for " + metadata.getKey() + " (" + file
                                        + ") from disabled repository " + repository.getId() + " ("
                                        + repository.getUrl() + ")");
                    }
                } else if (request.isForceUpdate()) {
                    update = true;
                } else if (localCopyLastModified != null && !policy.checkOutOfDate(localCopyLastModified)) {
                    update = false;

                    if (getLogger().isDebugEnabled()) {
                        getLogger()
                                .debug("Skipping update check for " + metadata.getKey() + " (" + file
                                        + ") from repository " + repository.getId() + " (" + repository.getUrl()
                                        + ") in favor of local copy");
                    }
                } else {
                    update = updateCheckManager.isUpdateRequired(metadata, repository, file);
                }

                if (update) {
                    getLogger().info(metadata.getKey() + ": checking for updates from " + repository.getId());
                    try {
                        wagonManager.getArtifactMetadata(metadata, repository, file, policy.getChecksumPolicy());
                    } catch (ResourceDoesNotExistException e) {
                        getLogger().debug(metadata + " could not be found on repository: " + repository.getId());

                        // delete the local copy so the old details aren't used.
                        if (file.exists()) {
                            if (!file.delete()) {
                                // sleep for 10ms just in case this is windows holding a file lock
                                try {
                                    Thread.sleep(10);
                                } catch (InterruptedException ie) {
                                    // ignore
                                }
                                file.delete(); // if this fails, forget about it
                            }
                        }
                    } catch (TransferFailedException e) {
                        getLogger()
                                .warn(metadata + " could not be retrieved from repository: " + repository.getId()
                                        + " due to an error: " + e.getMessage());
                        getLogger().debug("Exception", e);
                    } finally {
                        updateCheckManager.touch(metadata, repository, file);
                    }
                }

                // TODO should this be inside the above check?
                // touch file so that this is not checked again until interval has passed
                if (file.exists()) {
                    file.setLastModified(System.currentTimeMillis());
                }
            }
        }

        try {
            mergeMetadata(metadata, remoteRepositories, localRepo);
        } catch (RepositoryMetadataStoreException e) {
            throw new RepositoryMetadataResolutionException(
                    "Unable to store local copy of metadata: " + e.getMessage(), e);
        }
    }

    private Date getLocalCopyLastModified(ArtifactRepository localRepository, RepositoryMetadata metadata) {
        String metadataPath = localRepository.pathOfLocalRepositoryMetadata(metadata, localRepository);
        File metadataFile = new File(localRepository.getBasedir(), metadataPath);
        return metadataFile.isFile() ? new Date(metadataFile.lastModified()) : null;
    }

    private void mergeMetadata(
            RepositoryMetadata metadata,
            List<ArtifactRepository> remoteRepositories,
            ArtifactRepository localRepository)
            throws RepositoryMetadataStoreException {
        // TODO currently this is first wins, but really we should take the latest by comparing either the
        // snapshot timestamp, or some other timestamp later encoded into the metadata.
        // TODO this needs to be repeated here so the merging doesn't interfere with the written metadata
        //  - we'd be much better having a pristine input, and an ongoing metadata for merging instead

        Map<ArtifactRepository, Metadata> previousMetadata = new HashMap<>();
        ArtifactRepository selected = null;
        for (ArtifactRepository repository : remoteRepositories) {
            ArtifactRepositoryPolicy policy = metadata.getPolicy(repository);

            if (policy.isEnabled() && loadMetadata(metadata, repository, localRepository, previousMetadata)) {
                metadata.setRepository(repository);
                selected = repository;
            }
        }
        if (loadMetadata(metadata, localRepository, localRepository, previousMetadata)) {
            metadata.setRepository(null);
            selected = localRepository;
        }

        updateSnapshotMetadata(metadata, previousMetadata, selected, localRepository);
    }

    private void updateSnapshotMetadata(
            RepositoryMetadata metadata,
            Map<ArtifactRepository, Metadata> previousMetadata,
            ArtifactRepository selected,
            ArtifactRepository localRepository)
            throws RepositoryMetadataStoreException {
        // TODO this could be a lot nicer... should really be in the snapshot transformation?
        if (metadata.isSnapshot()) {
            Metadata prevMetadata = metadata.getMetadata();

            for (ArtifactRepository repository : previousMetadata.keySet()) {
                Metadata m = previousMetadata.get(repository);
                if (repository.equals(selected)) {
                    if (m.getVersioning() == null) {
                        m.setVersioning(new Versioning());
                    }

                    if (m.getVersioning().getSnapshot() == null) {
                        m.getVersioning().setSnapshot(new Snapshot());
                    }
                } else {
                    if ((m.getVersioning() != null)
                            && (m.getVersioning().getSnapshot() != null)
                            && m.getVersioning().getSnapshot().isLocalCopy()) {
                        m.getVersioning().getSnapshot().setLocalCopy(false);
                        metadata.setMetadata(m);
                        metadata.storeInLocalRepository(localRepository, repository);
                    }
                }
            }

            metadata.setMetadata(prevMetadata);
        }
    }

    private boolean loadMetadata(
            RepositoryMetadata repoMetadata,
            ArtifactRepository remoteRepository,
            ArtifactRepository localRepository,
            Map<ArtifactRepository, Metadata> previousMetadata) {
        boolean setRepository = false;

        File metadataFile = new File(
                localRepository.getBasedir(),
                localRepository.pathOfLocalRepositoryMetadata(repoMetadata, remoteRepository));

        if (metadataFile.exists()) {
            Metadata metadata;

            try {
                metadata = readMetadata(metadataFile);
            } catch (RepositoryMetadataReadException e) {
                if (getLogger().isDebugEnabled()) {
                    getLogger().warn(e.getMessage(), e);
                } else {
                    getLogger().warn(e.getMessage());
                }
                return setRepository;
            }

            if (repoMetadata.isSnapshot() && (previousMetadata != null)) {
                previousMetadata.put(remoteRepository, metadata);
            }

            if (repoMetadata.getMetadata() != null) {
                setRepository = repoMetadata.getMetadata().merge(metadata);
            } else {
                repoMetadata.setMetadata(metadata);
                setRepository = true;
            }
        }
        return setRepository;
    }

    /**
     * TODO share with DefaultPluginMappingManager.
     */
    protected Metadata readMetadata(File mappingFile) throws RepositoryMetadataReadException {
        Metadata result;

        try (Reader reader = ReaderFactory.newXmlReader(mappingFile)) {
            MetadataXpp3Reader mappingReader = new MetadataXpp3Reader();

            result = mappingReader.read(reader, false);
        } catch (FileNotFoundException e) {
            throw new RepositoryMetadataReadException("Cannot read metadata from '" + mappingFile + "'", e);
        } catch (IOException | XmlPullParserException e) {
            throw new RepositoryMetadataReadException(
                    "Cannot read metadata from '" + mappingFile + "': " + e.getMessage(), e);
        }
        return result;
    }

    /**
     * Ensures the last updated timestamp of the specified metadata does not refer to the future and fixes the local
     * metadata if necessary to allow proper merging/updating of metadata during deployment.
     */
    private void fixTimestamp(File metadataFile, Metadata metadata, Metadata reference) {
        boolean changed = false;

        if (metadata != null && reference != null) {
            Versioning versioning = metadata.getVersioning();
            Versioning versioningRef = reference.getVersioning();
            if (versioning != null && versioningRef != null) {
                String lastUpdated = versioning.getLastUpdated();
                String now = versioningRef.getLastUpdated();
                if (lastUpdated != null && now != null && now.compareTo(lastUpdated) < 0) {
                    getLogger()
                            .warn("The last updated timestamp in " + metadataFile + " refers to the future (now = "
                                    + now
                                    + ", lastUpdated = " + lastUpdated + "). Please verify that the clocks of all"
                                    + " deploying machines are reasonably synchronized.");
                    versioning.setLastUpdated(now);
                    changed = true;
                }
            }
        }

        if (changed) {
            getLogger().debug("Repairing metadata in " + metadataFile);

            try (Writer writer = WriterFactory.newXmlWriter(metadataFile)) {
                new MetadataXpp3Writer().write(writer, metadata);
            } catch (IOException e) {
                String msg = "Could not write fixed metadata to " + metadataFile + ": " + e.getMessage();
                if (getLogger().isDebugEnabled()) {
                    getLogger().warn(msg, e);
                } else {
                    getLogger().warn(msg);
                }
            }
        }
    }

    public void resolveAlways(
            RepositoryMetadata metadata, ArtifactRepository localRepository, ArtifactRepository remoteRepository)
            throws RepositoryMetadataResolutionException {
        File file;
        try {
            file = getArtifactMetadataFromDeploymentRepository(metadata, localRepository, remoteRepository);
        } catch (TransferFailedException e) {
            throw new RepositoryMetadataResolutionException(
                    metadata + " could not be retrieved from repository: " + remoteRepository.getId()
                            + " due to an error: " + e.getMessage(),
                    e);
        }

        try {
            if (file.exists()) {
                Metadata prevMetadata = readMetadata(file);
                metadata.setMetadata(prevMetadata);
            }
        } catch (RepositoryMetadataReadException e) {
            throw new RepositoryMetadataResolutionException(e.getMessage(), e);
        }
    }

    private File getArtifactMetadataFromDeploymentRepository(
            ArtifactMetadata metadata, ArtifactRepository localRepo, ArtifactRepository remoteRepository)
            throws TransferFailedException {
        File file =
                new File(localRepo.getBasedir(), localRepo.pathOfLocalRepositoryMetadata(metadata, remoteRepository));

        try {
            wagonManager.getArtifactMetadataFromDeploymentRepository(
                    metadata, remoteRepository, file, ArtifactRepositoryPolicy.CHECKSUM_POLICY_WARN);
        } catch (ResourceDoesNotExistException e) {
            getLogger()
                    .info(metadata + " could not be found on repository: " + remoteRepository.getId()
                            + ", so will be created");

            // delete the local copy so the old details aren't used.
            if (file.exists()) {
                if (!file.delete()) {
                    // sleep for 10ms just in case this is windows holding a file lock
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException ie) {
                        // ignore
                    }
                    file.delete(); // if this fails, forget about it
                }
            }
        } finally {
            if (metadata instanceof RepositoryMetadata) {
                updateCheckManager.touch((RepositoryMetadata) metadata, remoteRepository, file);
            }
        }
        return file;
    }

    public void deploy(
            ArtifactMetadata metadata, ArtifactRepository localRepository, ArtifactRepository deploymentRepository)
            throws RepositoryMetadataDeploymentException {
        File file;
        if (metadata instanceof RepositoryMetadata) {
            getLogger().info("Retrieving previous metadata from " + deploymentRepository.getId());
            try {
                file = getArtifactMetadataFromDeploymentRepository(metadata, localRepository, deploymentRepository);
            } catch (TransferFailedException e) {
                throw new RepositoryMetadataDeploymentException(
                        metadata + " could not be retrieved from repository: " + deploymentRepository.getId()
                                + " due to an error: " + e.getMessage(),
                        e);
            }

            if (file.isFile()) {
                try {
                    fixTimestamp(file, readMetadata(file), ((RepositoryMetadata) metadata).getMetadata());
                } catch (RepositoryMetadataReadException e) {
                    // will be reported via storeInlocalRepository
                }
            }
        } else {
            // It's a POM - we don't need to retrieve it first
            file = new File(
                    localRepository.getBasedir(),
                    localRepository.pathOfLocalRepositoryMetadata(metadata, deploymentRepository));
        }

        try {
            metadata.storeInLocalRepository(localRepository, deploymentRepository);
        } catch (RepositoryMetadataStoreException e) {
            throw new RepositoryMetadataDeploymentException("Error installing metadata: " + e.getMessage(), e);
        }

        try {
            wagonManager.putArtifactMetadata(file, metadata, deploymentRepository);
        } catch (TransferFailedException e) {
            throw new RepositoryMetadataDeploymentException("Error while deploying metadata: " + e.getMessage(), e);
        }
    }

    public void install(ArtifactMetadata metadata, ArtifactRepository localRepository)
            throws RepositoryMetadataInstallationException {
        try {
            metadata.storeInLocalRepository(localRepository, localRepository);
        } catch (RepositoryMetadataStoreException e) {
            throw new RepositoryMetadataInstallationException("Error installing metadata: " + e.getMessage(), e);
        }
    }
}
