package jenkins.model;

import com.google.common.base.Predicate;
import hudson.Extension;
import hudson.Util;
import hudson.model.Job;
import hudson.model.PermalinkProjectAction.Permalink;
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.model.listeners.RunListener;
import hudson.util.AtomicFileWriter;
import hudson.util.StreamTaskListener;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.apache.commons.io.FileUtils;

/**
 * Convenient base implementation for {@link Permalink}s that satisfy
 * certain properties.
 *
 * <p>
 * For a permalink to be able to use this, it has to satisfy the following:
 *
 * <blockquote>
 *     Given a job J, permalink is a function F that computes a build B.
 *     A peephole permalink is a subset of this function that can be
 *     deduced to the "peep-hole" function G(B)→bool:
 *
 *     <pre>
 *         F(J) = { newest B | G(B)==true }
 *     </pre>
 * </blockquote>
 *
 * <p>
 * Intuitively speaking, a peep-hole permalink resolves to the latest build that
 * satisfies a certain characteristics that can be determined solely by looking
 * at the build and nothing else (most commonly its build result.)
 *
 * <p>
 * This base class provides a file-based caching mechanism that avoids
 * walking the long build history. The cache is a symlink to the build directory
 * where symlinks are supported, and text file that contains the build number otherwise.
 *
 * <p>
 * The implementation transparently tolerates G(B) that goes from true to false over time
 * (it simply scans the history till find the new matching build.) To tolerate G(B)
 * that goes from false to true, you need to be able to intercept whenever G(B) changes
 * from false to true, then call {@link #resolve(Job)} to check the current permalink target
 * is up to date, then call {@link #updateCache(Job, Run)} if it needs updating.
 *
 * @author Kohsuke Kawaguchi
 * @since 1.507
 */
public abstract class PeepholePermalink extends Permalink implements Predicate<Run<?,?>> {

    /** JENKINS-22822: avoids rereading symlinks */
    static final Map<File,String> symlinks = new HashMap<File,String>();

    /**
     * Checks if the given build satisfies the peep-hole criteria.
     *
     * This is the "G(B)" as described in the class javadoc.
     */
    public abstract boolean apply(Run<?,?> run);

    /**
     * The file in which the permalink target gets recorded.
     */
    protected File getPermalinkFile(Job<?,?> job) {
        return new File(job.getBuildDir(),getId());
    }

    /**
     * Resolves the permalink by using the cache if possible.
     */
    @Override
    public Run<?, ?> resolve(Job<?, ?> job) {
        File f = getPermalinkFile(job);
        Run<?,?> b=null;

        try {
            String target = readSymlink(f);
            if (target!=null) {
                int n = Integer.parseInt(Util.getFileName(target));
                if (n==RESOLVES_TO_NONE)  return null;

                b = job.getBuildByNumber(n);
                if (b!=null && apply(b))
                    return b;   // found it (in the most efficient way possible)

                // the cache is stale. start the search
                if (b==null)
                     b=job.getNearestOldBuild(n);
            }
        } catch (InterruptedException e) {
            LOGGER.log(Level.WARNING, "Failed to read permalink cache:" + f, e);
            // if we fail to read the cache, fall back to the re-computation
        } catch (NumberFormatException e) { 
            LOGGER.log(Level.WARNING, "Failed to parse the build number in the permalink cache:" + f, e);
            // if we fail to read the cache, fall back to the re-computation
        } catch (IOException e) {
            // this happens when the symlink doesn't exist
            // (and it cannot be distinguished from the case when the actual I/O error happened
        }

        if (b==null) {
            // no cache
            b = job.getLastBuild();
        }

        // start from the build 'b' and locate the build that matches the criteria going back in time
        b = find(b);

        updateCache(job,b);
        return b;
    }

    /**
     * Start from the build 'b' and locate the build that matches the criteria going back in time
     */
    private Run<?,?> find(Run<?,?> b) {
        for ( ; b!=null && !apply(b); b=b.getPreviousBuild())
            ;
        return b;
    }

    /**
     * Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
     */
    protected void updateCache(@Nonnull Job<?,?> job, @Nullable Run<?,?> b) {
        final int n = b==null ? RESOLVES_TO_NONE : b.getNumber();

        File cache = getPermalinkFile(job);
        cache.getParentFile().mkdirs();

        try {
            String target = String.valueOf(n);
            if (b != null && !new File(job.getBuildDir(), target).exists()) {
                // (re)create the build Number->Id symlink
                Util.createSymlink(job.getBuildDir(),b.getId(),target,TaskListener.NULL);
            }
            writeSymlink(cache, target);
        } catch (IOException e) {
            LOGGER.log(Level.WARNING, "Failed to update "+job+" "+getId()+" permalink for " + b, e);
            cache.delete();
        } catch (InterruptedException e) {
            LOGGER.log(Level.WARNING, "Failed to update "+job+" "+getId()+" permalink for " + b, e);
            cache.delete();
        }
    }

    // File.exists returns false for a link with a missing target, so for Java 6 compatibility we have to use this circuitous method to see if it was created.
    private static boolean exists(File link) {
        File[] kids = link.getParentFile().listFiles();
        return kids != null && Arrays.asList(kids).contains(link);
    }

    static String readSymlink(File cache) throws IOException, InterruptedException {
        synchronized (symlinks) {
            String target = symlinks.get(cache);
            if (target != null) {
                LOGGER.log(Level.FINE, "readSymlink cached {0} → {1}", new Object[] {cache, target});
                return target;
            }
        }
        String target = Util.resolveSymlink(cache);
        if (target==null && cache.exists()) {
            // if this file isn't a symlink, it must be a regular file
            target = FileUtils.readFileToString(cache,"UTF-8").trim();
        }
        LOGGER.log(Level.FINE, "readSymlink {0} → {1}", new Object[] {cache, target});
        synchronized (symlinks) {
            symlinks.put(cache, target);
        }
        return target;
    }

    static void writeSymlink(File cache, String target) throws IOException, InterruptedException {
        LOGGER.log(Level.FINE, "writeSymlink {0} → {1}", new Object[] {cache, target});
        synchronized (symlinks) {
            symlinks.put(cache, target);
        }
        StringWriter w = new StringWriter();
        StreamTaskListener listener = new StreamTaskListener(w);
        Util.createSymlink(cache.getParentFile(),target,cache.getName(),listener);
        // Avoid calling resolveSymlink on a nonexistent file as it will probably throw an IOException:
        if (!exists(cache) || Util.resolveSymlink(cache)==null) {
          // symlink not supported. use a regular file
          AtomicFileWriter cw = new AtomicFileWriter(cache);
          try {
              cw.write(target);
              cw.commit();
          } finally {
              cw.abort();
          }
        }
    }

    @Extension
    public static class RunListenerImpl extends RunListener<Run<?,?>> {
        /**
         * If any of the peephole permalink points to the build to be deleted, update it to point to the new location.
         */
        @Override
        public void onDeleted(Run run) {
            Job<?, ?> j = run.getParent();
            for (PeepholePermalink pp : Util.filter(j.getPermalinks(), PeepholePermalink.class)) {
                if (pp.resolve(j)==run) {
                    Run<?,?> r = pp.find(run.getPreviousBuild());
                    if (LOGGER.isLoggable(Level.FINE))
                        LOGGER.fine("Updating "+pp.getPermalinkFile(j).getName()+" permalink from deleted "+run.getNumber()+" to "+(r == null ? -1 : r.getNumber()));
                    pp.updateCache(j,r);
                }
            }
        }

        /**
         * See if the new build matches any of the peephole permalink.
         */
        @Override
        public void onCompleted(Run<?,?> run, @Nonnull TaskListener listener) {
            Job<?, ?> j = run.getParent();
            for (PeepholePermalink pp : Util.filter(j.getPermalinks(), PeepholePermalink.class)) {
                if (pp.apply(run)) {
                    Run<?, ?> cur = pp.resolve(j);
                    if (cur==null || cur.getNumber()<run.getNumber()) {
                        if (LOGGER.isLoggable(Level.FINE))
                            LOGGER.fine("Updating "+pp.getPermalinkFile(j).getName()+" permalink to completed "+run.getNumber());
                        pp.updateCache(j,run);
                    }
                }
            }
        }
    }

    private static final int RESOLVES_TO_NONE = -1;

    private static final Logger LOGGER = Logger.getLogger(PeepholePermalink.class.getName());
}
