/*
 * The MIT License
 *
 * Copyright 2014 Jesse Glick.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package jenkins.model;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Extension;
import hudson.Util;
import hudson.model.Job;
import hudson.model.RootAction;
import hudson.model.Run;
import hudson.util.AtomicFileWriter;
import hudson.util.StreamTaskListener;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.time.FastDateFormat;
import org.apache.tools.ant.BuildException;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.StaplerProxy;
import org.kohsuke.stapler.framework.io.WriterOutputStream;

import static java.util.logging.Level.FINE;
import static java.util.logging.Level.FINER;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;

/**
 * Converts legacy {@code builds} directories to the current format.
 *
 * There would be one instance associated with each {@link Job}, to retain ID → build# mapping.
 *
 * The {@link Job#getBuildDir} is passed to every method call (rather than being cached) in case it is moved.
 */
@Restricted(NoExternalUse.class)
public final class RunIdMigrator {

    private final DateFormat legacyIdFormatter = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");

    static final Logger LOGGER = Logger.getLogger(RunIdMigrator.class.getName());
    private static final String MAP_FILE = "legacyIds";
    /** avoids wasting a map for new jobs */
    private static final Map<String,Integer> EMPTY = new TreeMap<>();

    /**
     * Did we record "unmigrate" instruction for this $JENKINS_HOME? Yes if it's in the set.
     */
    private static final Set<File> offeredToUnmigrate = Collections.synchronizedSet(new HashSet<>());

    private @NonNull Map<String,Integer> idToNumber = EMPTY;

    public RunIdMigrator() {}

    /**
     * @return whether there was a file to load
     */
    private boolean load(File dir) {
        File f = new File(dir, MAP_FILE);
        if (!f.isFile()) {
            return false;
        }
        if (f.length() == 0) {
            return true;
        }
        idToNumber = new TreeMap<>();
        try {
            for (String line : FileUtils.readLines(f)) {
                int i = line.indexOf(' ');
                idToNumber.put(line.substring(0, i), Integer.parseInt(line.substring(i + 1)));
            }
        } catch (Exception x) { // IOException, IndexOutOfBoundsException, NumberFormatException
            LOGGER.log(WARNING, "could not read from " + f, x);
        }
        return true;
    }

    private void save(File dir) {
        File f = new File(dir, MAP_FILE);
        try (AtomicFileWriter w = new AtomicFileWriter(f)) {
            try {
                for (Map.Entry<String,Integer> entry : idToNumber.entrySet()) {
                    w.write(entry.getKey() + ' ' + entry.getValue() + '\n');
                }
                w.commit();
            } finally {
                w.abort();
            }
        } catch (IOException x) {
            LOGGER.log(WARNING, "could not save changes to " + f, x);
        }
    }

    /**
     * Called when a job is first created.
     * Just saves an empty marker indicating that this job needs no migration.
     * @param dir as in {@link Job#getBuildDir}
     */
    public void created(File dir) {
        save(dir);
    }

    /**
     * Perform one-time migration if this has not been done already.
     * Where previously there would be a {@code 2014-01-02_03-04-05/build.xml} specifying {@code <number>99</number>} plus a symlink {@code 99 → 2014-01-02_03-04-05},
     * after migration there will be just {@code 99/build.xml} specifying {@code <id>2014-01-02_03-04-05</id>} and {@code <timestamp>…</timestamp>} according to local time zone at time of migration.
     * Newly created builds are untouched.
     * Does not throw {@link IOException} since we make a best effort to migrate but do not consider it fatal to job loading if we cannot.
     * @param dir as in {@link Job#getBuildDir}
     * @param jenkinsHome root directory of Jenkins (for logging only)
     * @return true if migration was performed
     */
    public synchronized boolean migrate(File dir, @CheckForNull File jenkinsHome) {
        if (load(dir)) {
            LOGGER.log(FINER, "migration already performed for {0}", dir);
            return false;
        }
        if (!dir.isDirectory()) {
            LOGGER.log(/* normal during Job.movedTo */FINE, "{0} was unexpectedly missing", dir);
            return false;
        }
        LOGGER.log(INFO, "Migrating build records in {0}", dir);
        doMigrate(dir);
        save(dir);
        if (jenkinsHome != null && offeredToUnmigrate.add(jenkinsHome))
            LOGGER.log(WARNING, "Build record migration (https://jenkins.io/redirect/build-record-migration) is one-way. If you need to downgrade Jenkins, run: {0}", getUnmigrationCommandLine(jenkinsHome));
        return true;
    }

    private static String getUnmigrationCommandLine(File jenkinsHome) {
        StringBuilder cp = new StringBuilder();
        for (Class<?> c : new Class<?>[] {RunIdMigrator.class, /* TODO how to calculate transitive dependencies automatically? */WriterOutputStream.class, BuildException.class, FastDateFormat.class}) {
            URL location = c.getProtectionDomain().getCodeSource().getLocation();
            String locationS = location.toString();
            if (location.getProtocol().equals("file")) {
                try {
                    locationS = new File(location.toURI()).getAbsolutePath();
                } catch (URISyntaxException x) {
                    // never mind
                }
            }
            if (cp.length() > 0) {
                cp.append(File.pathSeparator);
            }
            cp.append(locationS);
        }
        return String.format("java -classpath \"%s\" %s \"%s\"", cp, RunIdMigrator.class.getName(), jenkinsHome);
    }

    private static final Pattern NUMBER_ELT = Pattern.compile("(?m)^  <number>(\\d+)</number>(\r?\n)");
    private void doMigrate(File dir) {
        idToNumber = new TreeMap<>();
        File[] kids = dir.listFiles();
        // Need to process symlinks first so we can rename to them.
        List<File> kidsList = new ArrayList<>(Arrays.asList(kids));
        Iterator<File> it = kidsList.iterator();
        while (it.hasNext()) {
            File kid = it.next();
            String name = kid.getName();
            try {
                Integer.parseInt(name);
            } catch (NumberFormatException x) {
                LOGGER.log(FINE, "ignoring nonnumeric entry {0}", name);
                continue;
            }
            try {
                if (Util.isSymlink(kid)) {
                    LOGGER.log(FINE, "deleting build number symlink {0} → {1}", new Object[] {name, Util.resolveSymlink(kid)});
                } else if (kid.isDirectory()) {
                    LOGGER.log(FINE, "ignoring build directory {0}", name);
                    continue;
                } else {
                    LOGGER.log(WARNING, "need to delete anomalous file entry {0}", name);
                }
                Util.deleteFile(kid);
                it.remove();
            } catch (Exception x) {
                LOGGER.log(WARNING, "failed to process " + kid, x);
            }
        }
        it = kidsList.iterator();
        while (it.hasNext()) {
            File kid = it.next();
            try {
                String name = kid.getName();
                try {
                    Integer.parseInt(name);
                    LOGGER.log(FINE, "skipping new build dir {0}", name);
                    continue;
                } catch (NumberFormatException x) {
                    // OK, next…
                }
                if (!kid.isDirectory()) {
                    LOGGER.log(FINE, "skipping non-directory {0}", name);
                    continue;
                }
                long timestamp;
                try {
                    synchronized (legacyIdFormatter) {
                        timestamp = legacyIdFormatter.parse(name).getTime();
                    }
                } catch (ParseException x) {
                    LOGGER.log(WARNING, "found unexpected dir {0}", name);
                    continue;
                }
                File buildXml = new File(kid, "build.xml");
                if (!buildXml.isFile()) {
                    LOGGER.log(WARNING, "found no build.xml in {0}", name);
                    continue;
                }
                String xml = FileUtils.readFileToString(buildXml, StandardCharsets.UTF_8);
                Matcher m = NUMBER_ELT.matcher(xml);
                if (!m.find()) {
                    LOGGER.log(WARNING, "could not find <number> in {0}/build.xml", name);
                    continue;
                }
                int number = Integer.parseInt(m.group(1));
                String nl = m.group(2);
                xml = m.replaceFirst("  <id>" + name + "</id>" + nl + "  <timestamp>" + timestamp + "</timestamp>" + nl);
                File newKid = new File(dir, Integer.toString(number));
                move(kid, newKid);
                FileUtils.writeStringToFile(new File(newKid, "build.xml"), xml, StandardCharsets.UTF_8);
                LOGGER.log(FINE, "fully processed {0} → {1}", new Object[] {name, number});
                idToNumber.put(name, number);
            } catch (Exception x) {
                LOGGER.log(WARNING, "failed to process " + kid, x);
            }
        }
    }

    /**
     * Tries to move/rename a file from one path to another.
     * Uses {@link java.nio.file.Files#move} when available.
     * Does not use {@link java.nio.file.StandardCopyOption#REPLACE_EXISTING} or any other options.
     * TODO candidate for moving to {@link Util}
     */
    static void move(File src, File dest) throws IOException {
        try {
            Files.move(src.toPath(), dest.toPath());
        } catch (IOException x) {
            throw x;
        } catch (Exception x) {
            throw new IOException(x);
        }
    }

    /**
     * Look up a historical run by ID.
     * @param id a nonnumeric ID which may be a valid {@link Run#getId}
     * @return the corresponding {@link Run#number}, or 0 if unknown
     */
    public synchronized int findNumber(@NonNull String id) {
        Integer number = idToNumber.get(id);
        return number != null ? number : 0;
    }

    /**
     * Delete the record of a build.
     * @param dir as in {@link Job#getBuildDir}
     * @param id a {@link Run#getId}
     */
    public synchronized void delete(File dir, String id) {
        if (idToNumber.remove(id) != null) {
            save(dir);
        }
    }

    /**
     * Reverses the migration, in case you want to revert to the older format.
     * @param args one parameter, {@code $JENKINS_HOME}
     */
    public static void main(String... args) throws Exception {
        if (args.length != 1) {
            throw new Exception("pass one parameter, $JENKINS_HOME");
        }
        File root = constructFile(args[0]);
        File jobs = new File(root, "jobs");
        if (!jobs.isDirectory()) {
            throw new FileNotFoundException("no such $JENKINS_HOME " + root);
        }
        new RunIdMigrator().unmigrateJobsDir(jobs);
    }

    @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "Only invoked from the command line as a standalone utility")
    private static File constructFile(String arg) {
        return new File(arg);
    }

    private void unmigrateJobsDir(File jobs) throws Exception {
        File[] jobDirs = jobs.listFiles();
        if (jobDirs == null) {
            System.err.println(jobs + " claimed to exist, but cannot be listed");
            return;
        }
        for (File job : jobDirs) {

            if (job.getName().equals("builds")) {
                // Might be maven modules, matrix builds, etc. which are direct children of job
                unmigrateBuildsDir(job);
            }

            File[] kids = job.listFiles();
            if (kids == null) {
                continue;
            }
            for (File kid : kids) {
                if (!kid.isDirectory()) {
                    continue;
                }
                if (kid.getName().equals("builds")) {
                    unmigrateBuildsDir(kid);
                } else {
                    // Might be jobs, modules, promotions, etc.; we assume an ItemGroup.getRootDirFor implementation
                    // returns grandchildren, unmigrateJobsDir(job) call above handles children.
                    unmigrateJobsDir(kid);
                }
            }
        }
    }
    private static final Pattern ID_ELT = Pattern.compile("(?m)^  <id>([0-9_-]+)</id>(\r?\n)");
    private static final Pattern TIMESTAMP_ELT = Pattern.compile("(?m)^  <timestamp>(\\d+)</timestamp>(\r?\n)");
    /** Inverse of {@link #doMigrate}. */
    private void unmigrateBuildsDir(File builds) throws Exception {
        File mapFile = new File(builds, MAP_FILE);
        if (!mapFile.isFile()) {
            System.err.println(builds + " does not look to have been migrated yet; skipping");
            return;
        }
        for (File build : builds.listFiles()) {
            int number;
            try {
                number = Integer.parseInt(build.getName());
            } catch (NumberFormatException x) {
                continue;
            }
            File buildXml = new File(build, "build.xml");
            if (!buildXml.isFile()) {
                System.err.println(buildXml + " did not exist");
                continue;
            }
            String xml = FileUtils.readFileToString(buildXml, StandardCharsets.UTF_8);
            Matcher m = TIMESTAMP_ELT.matcher(xml);
            if (!m.find()) {
                System.err.println(buildXml + " did not contain <timestamp> as expected");
                continue;
            }
            long timestamp = Long.parseLong(m.group(1));
            String nl = m.group(2);
            xml = m.replaceFirst("  <number>" + number + "</number>" + nl);
            m = ID_ELT.matcher(xml);
            String id;
            if (m.find()) {
                id = m.group(1);
                xml = m.replaceFirst("");
            } else {
                // Post-migration build. We give it a new ID based on its timestamp.
                id = legacyIdFormatter.format(new Date(timestamp));
            }
            FileUtils.write(buildXml, xml, StandardCharsets.UTF_8);
            if (!build.renameTo(new File(builds, id))) {
                System.err.println(build + " could not be renamed");
            }
            Util.createSymlink(builds, id, Integer.toString(number), StreamTaskListener.fromStderr());
        }
        Util.deleteFile(mapFile);
        System.err.println(builds + " has been restored to its original format");
    }

    /**
     * Expose unmigration instruction to the user.
     */
    @Extension
    public static class UnmigrationInstruction implements RootAction, StaplerProxy {
        @Override
        public String getIconFileName() {
            return null;
        }

        @Override
        public String getDisplayName() {
            return null;
        }

        @Override
        public String getUrlName() {
            return "JENKINS-24380";
        }

        @Override
        public Object getTarget() {
            Jenkins.get().checkPermission(Jenkins.ADMINISTER);
            return this;
        }

        public String getCommand() {
            return RunIdMigrator.getUnmigrationCommandLine(Jenkins.get().getRootDir());
        }
    }
}
