/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.admin.patch;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;

import org.alfresco.error.AlfrescoRuntimeException;
import org.springframework.extensions.surf.util.I18NUtil;
import org.alfresco.repo.batch.BatchProcessWorkProvider;
import org.alfresco.repo.batch.BatchProcessor;
import org.alfresco.repo.batch.BatchProcessor.BatchProcessWorker;
import org.alfresco.repo.node.integrity.IntegrityChecker;
import org.alfresco.repo.security.authentication.AuthenticationContext;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork;
import org.alfresco.repo.tenant.Tenant;
import org.alfresco.repo.tenant.TenantAdminService;
import org.alfresco.repo.transaction.RetryingTransactionHelper;
import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback;
import org.alfresco.service.cmr.admin.PatchException;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.search.SearchService;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.transaction.TransactionService;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;

/**
 * Base implementation of the patch. This class ensures that the patch is thread- and transaction-safe.
 * 
 * @author Derek Hulley
 */
public abstract class AbstractPatch implements Patch,  ApplicationEventPublisherAware
{
    /**
     * I18N message when properties not set.
     * <ul>
     * <li>{0} = property name</li>
     * <li>{1} = patch instance</li>
     * </ul>
     */
    public static final String ERR_PROPERTY_NOT_SET = "patch.general.property_not_set";
    private static final String MSG_PROGRESS = "patch.progress";
    private static final String MSG_DEFERRED = "patch.genericBootstrap.result.deferred";

    private static final long RANGE_10 = 1000 * 60 * 90;
    private static final long RANGE_5 = 1000 * 60 * 60 * 4;
    private static final long RANGE_2 = 1000 * 60 * 90 * 10;

    private static Log logger = LogFactory.getLog(AbstractPatch.class);
    private static Log progress_logger = LogFactory.getLog(PatchExecuter.class);

    private String id;
    private int fixesFromSchema;
    private int fixesToSchema;
    private int targetSchema;
    private boolean force;
    private String description;
    private boolean ignored;
    /** a list of patches that this one depends on */
    private List<Patch> dependsOn;
    /** a list of patches that, if already present, mean that this one should be ignored */
    private List<Patch> alternatives;
    /** flag indicating if the patch was successfully applied */
    private boolean applied;
    private boolean applyToTenants;
    /** track completion * */
    int percentComplete = 0;
    /** start time * */
    long startTime;
    
    /** whether the patch must be deferred (not to be executed in bootstrap) or not */
    private boolean deferred = false;    

    
    // Does the patch require an enclosing transaction?
    private boolean requiresTransaction = true;

    /** the service to register ourselves with */
    protected PatchService patchService;
    /** used to ensure a unique transaction per execution */
    protected TransactionService transactionService;
    /** Use this helper to ensure that patches can execute even on a read-only system */
    protected RetryingTransactionHelper transactionHelper;
    protected NamespaceService namespaceService;
    protected NodeService nodeService;
    protected SearchService searchService;
    protected AuthenticationContext authenticationContext;
    protected TenantAdminService tenantAdminService;
    /** Publishes batch event notifications for JMX viewing */
    protected ApplicationEventPublisher applicationEventPublisher;

    public AbstractPatch()
    {
        this.fixesFromSchema = -1;
        this.fixesToSchema = -1;
        this.targetSchema = -1;
        this.force = false;
        this.applied = false;
        this.applyToTenants = true;     // by default, apply to each tenant, if tenant service is enabled
        this.dependsOn = Collections.emptyList();
        this.alternatives = Collections.emptyList();
        this.ignored = false;
    }

    @Override
    public String toString()
    {
        StringBuilder sb = new StringBuilder(256);
        sb.append("Patch")
          .append("[ id=").append(id)
          .append(", description=").append(description)
          .append(", fixesFromSchema=").append(fixesFromSchema)
          .append(", fixesToSchema=").append(fixesToSchema)
          .append(", targetSchema=").append(targetSchema)
          .append(", ignored=").append(ignored)
          .append("]");
        return sb.toString();
    }

    /**
     * Set the service that this patch will register with for execution.
     */
    public void setPatchService(PatchService patchService)
    {
        this.patchService = patchService;
    }

    /**
     * Set the transaction provider so that each execution can be performed within a transaction
     */
    public void setTransactionService(TransactionService transactionService)
    {
        this.transactionService = transactionService;
        this.transactionHelper = transactionService.getRetryingTransactionHelper();
        this.transactionHelper.setForceWritable(true);
    }

    public void setNamespaceService(NamespaceService namespaceService)
    {
        this.namespaceService = namespaceService;
    }

    public void setNodeService(NodeService nodeService)
    {
        this.nodeService = nodeService;
    }

    public void setSearchService(SearchService searchService)
    {
        this.searchService = searchService;
    }

    public void setAuthenticationContext(AuthenticationContext authenticationContext)
    {
        this.authenticationContext = authenticationContext;
    }
    
    public void setTenantAdminService(TenantAdminService tenantAdminService)
    {
        this.tenantAdminService = tenantAdminService;
    }

    /**
     * Set automatically
     */
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher)
    {
        this.applicationEventPublisher = applicationEventPublisher;
    }

    /**
     * This ensures that this bean gets registered with the appropriate {@link PatchService service}.
     */
    public void init()
    {
        if (patchService == null)
        {
            throw new AlfrescoRuntimeException("Mandatory property not set: patchService");
        }
        if(false == isIgnored())
        {
            patchService.registerPatch(this);
        }
    }

    public String getId()
    {
        return id;
    }

    /**
     * @param id
     *            the unique ID of the patch. This dictates the order in which patches are applied.
     */
    public void setId(String id)
    {
        this.id = id;
    }

    public int getFixesFromSchema()
    {
        return fixesFromSchema;
    }

    public void setRequiresTransaction(boolean requiresTransaction)
    {
        this.requiresTransaction = requiresTransaction;
    }
    
    public boolean requiresTransaction()
    {
        return requiresTransaction;
    }
    
    /**
     * Set the smallest schema number that this patch may be applied to.
     * 
     * @param version
     *            a schema number not smaller than 0
     */
    public void setFixesFromSchema(int version)
    {
        if (version < 0)
        {
            throw new IllegalArgumentException("The 'fixesFromSchema' property may not be less than 0");
        }
        this.fixesFromSchema = version;
        // auto-adjust the to version
        if (fixesToSchema < fixesFromSchema)
        {
            setFixesToSchema(this.fixesFromSchema);
        }
    }

    public int getFixesToSchema()
    {
        return fixesToSchema;
    }

    /**
     * Set the largest schema version number that this patch may be applied to.
     * 
     * @param version
     *            a schema version number not smaller than the {@link #setFixesFromSchema(int) from version} number.
     */
    public void setFixesToSchema(int version)
    {
        if (version < fixesFromSchema)
        {
            throw new IllegalArgumentException("'fixesToSchema' must be greater than or equal to 'fixesFromSchema'");
        }
        this.fixesToSchema = version;
    }

    public int getTargetSchema()
    {
        return targetSchema;
    }

    /**
     * Set the schema version that this patch attempts to take the existing schema to. This is for informational
     * purposes only, acting as an indicator of intention rather than having any specific effect.
     * 
     * @param version
     *            a schema version number that must be greater than the {@link #fixesToSchema max fix schema number}
     */
    public void setTargetSchema(int version)
    {
        if (version <= fixesToSchema)
        {
            throw new IllegalArgumentException("'targetSchema' must be greater than 'fixesToSchema'");
        }
        this.targetSchema = version;
    }

    /**
     * {@inheritDoc}
     */
    public boolean isForce()
    {
        return force;
    }

    /**
     * Set the flag that forces the patch to be forcefully applied.  This allows patches to be overridden to induce execution
     * regardless of the upgrade or installation versions, or even if the patch has been executed before.
     * 
     * @param force         <tt>true</tt> to force the patch to be applied
     */
    public void setForce(boolean force)
    {
        this.force = force;
    }

    public String getDescription()
    {
        return description;
    }

    /**
     * @param description
     *            a thorough description of the patch
     */
    public void setDescription(String description)
    {
        this.description = description;
    }
    
    /**
     * @return the ignored
     */
    public boolean isIgnored()
    {
        return ignored;
    }

    /**
     * @param ignored the ignored to set
     */
    public void setIgnored(boolean ignored)
    {
        this.ignored = ignored;
    }

    public List<Patch> getDependsOn()
    {
        return this.dependsOn;
    }

    /**
     * Set all the dependencies for this patch. It should not be executed before all the dependencies have been applied.
     * 
     * @param dependsOn
     *            a list of dependencies
     */
    public void setDependsOn(List<Patch> dependsOn)
    {
        this.dependsOn = dependsOn;
    }

    public List<Patch> getAlternatives()
    {
        return alternatives;
    }

    /**
     * Set all anti-dependencies.  If any of the patches in the list have already been executed, then
     * this one need not be.
     * 
     * @param alternatives          a list of alternative patches
     */
    public void setAlternatives(List<Patch> alternatives)
    {
        this.alternatives = alternatives;
    }

    public boolean applies(int version)
    {
        return ((this.fixesFromSchema <= version) && (version <= fixesToSchema));
    }

    /**
     * Performs a null check on the supplied value.
     * 
     * @param value
     *            value to check
     * @param name
     *            name of the property to report
     */
    protected final void checkPropertyNotNull(Object value, String name)
    {
        if (value == null)
        {
            throw new PatchException(ERR_PROPERTY_NOT_SET, name, this);
        }
    }
    
    public void setApplyToTenants(boolean applyToTenants)
    {
        this.applyToTenants = applyToTenants;
    }

    /**
     * Check that the schema version properties have been set appropriately. Derived classes can override this method to
     * perform their own validation provided that this method is called by the derived class.
     */
    protected void checkProperties()
    {
        // check that the necessary properties have been set
        checkPropertyNotNull(id, "id");
        checkPropertyNotNull(description, "description");
        checkPropertyNotNull(transactionService, "transactionService");
        checkPropertyNotNull(transactionHelper, "transactionHelper");
        checkPropertyNotNull(namespaceService, "namespaceService");
        checkPropertyNotNull(nodeService, "nodeService");
        checkPropertyNotNull(searchService, "searchService");
        checkPropertyNotNull(authenticationContext, "authenticationContext");
        checkPropertyNotNull(tenantAdminService, "tenantAdminService");
        checkPropertyNotNull(applicationEventPublisher, "applicationEventPublisher");
        if (fixesFromSchema == -1 || fixesToSchema == -1 || targetSchema == -1)
        {
            throw new AlfrescoRuntimeException(
                    "Patch properties 'fixesFromSchema', 'fixesToSchema' and 'targetSchema' " +
                    "have not all been set on this patch: \n"
                    + "   patch: " + this);
        }
    }

    private String applyWithTxns() throws Exception
    {
        if(logger.isDebugEnabled())
        {
            logger.debug("call applyInternal for main context id:" + id);
        }
        RetryingTransactionCallback<String> patchWork = new RetryingTransactionCallback<String>()
        {
            public String execute() throws Exception
            {
                // downgrade integrity checking
                IntegrityChecker.setWarnInTransaction();

                return applyInternal();
            }
        };
        StringBuilder sb = new StringBuilder(128);
        if (requiresTransaction())
        {
            // execute in a transaction
            String temp = this.transactionHelper.doInTransaction(patchWork, false, true);
            sb.append(temp);
        }
        else
        {
            String temp = applyInternal();
            sb.append(temp);
        }
        
        if ((tenantAdminService != null) && tenantAdminService.isEnabled() && applyToTenants)
        {
            if(logger.isDebugEnabled())
            {
                logger.debug("call applyInternal for all tennants");
            }
            final List<Tenant> tenants = tenantAdminService.getAllTenants();
                        
            BatchProcessWorkProvider<Tenant> provider = new BatchProcessWorkProvider<Tenant>()
            {
                Iterator<Tenant> i = tenants.iterator();

                @Override
                public int getTotalEstimatedWorkSize()
                {
                    return tenants.size();
                }

                @Override
                public Collection<Tenant> getNextWork()
                {
                    // return chunks of 10 tenants
                    ArrayList<Tenant> chunk = new ArrayList<Tenant>(100);
                    
                    while(i.hasNext() && chunk.size() <= 100)
                    {
                        chunk.add(i.next());
                    }
                    return chunk;
                }
            };

            BatchProcessor<Tenant> batchProcessor = new BatchProcessor<Tenant>(
                    "AbstractPatch Processor for " + id,
                    transactionHelper,
                    provider, // collection of tenants
                    10, // worker threads, 
                    100, // batch size 100,
                    applicationEventPublisher,
                    logger,
                    1000);
            
            BatchProcessWorker<Tenant> worker = new BatchProcessWorker<Tenant>()
            {
                @Override
                public String getIdentifier(Tenant entry)
                {
                    return entry.getTenantDomain();
                }

                @Override
                public void beforeProcess() throws Throwable
                {
                    
                }

                @Override
                public void process(Tenant entry) throws Throwable
                {
                    String tenantDomain = entry.getTenantDomain();
                    @SuppressWarnings("unused")
                    String tenantReport = AuthenticationUtil.runAs(new RunAsWork<String>()
                    {
                        public String doWork() throws Exception
                        {
                            return applyInternal();
                        }
                    }, tenantAdminService.getDomainUser(AuthenticationUtil.getSystemUserName(), tenantDomain));                    
                }

                @Override
                public void afterProcess() throws Throwable
                {
                    
                }
            };
            
            // Now do the work
            @SuppressWarnings("unused")
            int numberOfInvocations = batchProcessor.process(worker, true);
            
            if (logger.isDebugEnabled())
            {
                logger.debug("batch worker finished processing id:" + id);
            }
            
            if (batchProcessor.getTotalErrors() > 0)
            {
                sb.append("\n" + " and failure during update of tennants total success: " + batchProcessor.getSuccessfullyProcessedEntries() + " number of errors: " +batchProcessor.getTotalErrors() + " lastError" + batchProcessor.getLastError());
            }
            else
            {
                sb.append("\n" + " and successful batch update of " + batchProcessor.getTotalResults() + "tennants");
            }
        }

        // Done
        return sb.toString();
    }
    
    /**
     * {@inheritDoc}
     */
    public String applyAsync() throws PatchException
    {    
        return apply(true);
    }

    /**
     * Sets up the transaction and ensures thread-safety.
     * 
     * @see #applyInternal()
     */
    public synchronized String apply() throws PatchException
    {
        return apply(false);
    }
    
    private String apply(boolean async)
    {
        if (!async)
        {
            // Do we bug out of patch execution
            if (deferred)
            {
                return I18NUtil.getMessage(MSG_DEFERRED);
            }
  
            // ensure that this has not been executed already
            if (applied)
            {
                throw new AlfrescoRuntimeException("The patch has already been executed: \n" + "   patch: " + this);
            }
        }
        
        // check properties
        checkProperties();

        if (logger.isDebugEnabled())
        {
            logger.debug("\n" + "Patch will be applied: \n" + "   patch: " + this);
        }

        try
        {
            AuthenticationUtil.RunAsWork<String> applyPatchWork = new AuthenticationUtil.RunAsWork<String>()
            {
                public String doWork() throws Exception
                {
                    return applyWithTxns();
                }
            };
            startTime = System.currentTimeMillis();
            String report = AuthenticationUtil.runAs(applyPatchWork, AuthenticationUtil.getSystemUserName());
            // the patch was successfully applied
            applied = true;
            // done
            if (logger.isDebugEnabled())
            {
                logger.debug("\n" + "Patch successfully applied: \n" + "   patch: " + this + "\n" + "   report: " + report);
            }
            return report;
        }
        catch (PatchException e)
        {
            // no need to extract the exception
            throw e;
        }
        catch (Throwable e)
        {
            // check whether there is an embedded patch exception
            Throwable cause = e.getCause();
            if (cause != null && cause instanceof PatchException)
            {
                throw (PatchException) cause;
            }
            // need to generate a message from the exception
            String report = makeReport(e);
            // generate the correct exception
            throw new PatchException(report);
        }
    }

    /**
     * Dumps the error's full message and trace to the String
     * 
     * @param e
     *            the throwable
     * @return Returns a String representative of the printStackTrace method
     */
    private String makeReport(Throwable e)
    {
        StringWriter stringWriter = new StringWriter(1024);
        PrintWriter printWriter = new PrintWriter(stringWriter, true);
        try
        {
            e.printStackTrace(printWriter);
            return stringWriter.toString();
        }
        finally
        {
            printWriter.close();
        }
    }

    /**
     * This method does the work. All transactions and thread-safety will be taken care of by this class. Any exception
     * will result in the transaction being rolled back. Integrity checks are downgraded for the duration of the
     * transaction.
     * 
     * @return Returns the report (only success messages).
     * @see #apply()
     * @throws Exception
     *             anything can be thrown. This must be used for all failures.
     */
    protected abstract String applyInternal() throws Exception;

    /**
     * Support to report patch completion and estimated completion time.
     * 
     * @param estimatedTotal long
     * @param currentInteration long
     */
    protected void reportProgress(long estimatedTotal, long currentInteration)
    {
        if (progress_logger.isDebugEnabled())
        {
            progress_logger.debug(currentInteration + "/" + estimatedTotal);
        }
        if (currentInteration == 0)
        {
            // No point reporting the start - we have already done that elsewhere ....
            percentComplete = 0;
        }
        else if (currentInteration * 100l / estimatedTotal > percentComplete)
        {
            int previous = percentComplete;
            percentComplete = (int) (currentInteration * 100l / estimatedTotal);

            if (percentComplete < 100)
            {
                // conditional report

                long currentTime = System.currentTimeMillis();
                long timeSoFar = currentTime - startTime;
                long timeRemaining = timeSoFar * (100 - percentComplete) / percentComplete;

                int report = -1;

                if (timeRemaining > 60000)
                {
                    int reportInterval = getReportingInterval(timeSoFar, timeRemaining);

                    for (int i = previous + 1; i <= percentComplete; i++)
                    {
                        if (i % reportInterval == 0)
                        {
                            report = i;
                        }
                    }
                    if (report > 0)
                    {
                        Date end = new Date(currentTime + timeRemaining);

                        String msg = I18NUtil.getMessage(MSG_PROGRESS, getId(), report, end);
                        progress_logger.info(msg);
                    }
                }
            }
        }
    }
    
    /**
     * Should the patch be deferred? And not run at bootstrap.
     * @param deferred boolean
     */
    public void setDeferred(boolean deferred)
    {
        this.deferred = deferred;
    }

    public boolean isDeferred()
    {
        return this.deferred;
    }    

    private int getReportingInterval(long soFar, long toGo)
    {
        long total = soFar + toGo;
        if (total < RANGE_10)
        {
            return 10;
        }
        else if (total < RANGE_5)
        {
            return 5;
        }
        else if (total < RANGE_2)
        {
            return 2;
        }
        else
        {
            return 1;
        }

    }
}
