/*
 * Copyright (c) 2007-2012 The Broad Institute, Inc.
 * SOFTWARE COPYRIGHT NOTICE
 * This software and its documentation are the copyright of the Broad Institute, Inc. All rights are reserved.
 *
 * This software is supplied without any warranty or guaranteed support whatsoever. The Broad Institute is not responsible for its use, misuse, or functionality.
 *
 * This software is licensed under the terms of the GNU Lesser General Public License (LGPL),
 * Version 2.1 which is available at http://www.opensource.org/licenses/lgpl-2.1.php.
 */

package org.broad.igv.cli_plugin;

import org.apache.log4j.Logger;
import org.broad.igv.feature.genome.GenomeManager;
import org.broad.igv.feature.tribble.CodecFactory;
import org.broad.igv.feature.tribble.IGVBEDCodec;
import org.broad.igv.sam.Alignment;
import org.broad.igv.sam.AlignmentInterval;
import org.broad.igv.sam.AlignmentTrack;
import org.broad.igv.session.IGVSessionReader;
import org.broad.igv.session.SubtlyImportant;
import org.broad.igv.track.FeatureTrack;
import org.broad.igv.track.Track;
import org.broad.igv.util.FileUtils;
import org.broad.igv.util.RuntimeUtils;
import org.broad.tribble.AsciiFeatureCodec;
import org.broad.tribble.Feature;
import org.broad.tribble.FeatureCodec;
import org.broad.tribble.bed.SimpleBEDFeature;
import org.broad.tribble.readers.PositionalBufferedStream;

import javax.xml.bind.annotation.*;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.io.*;
import java.lang.reflect.Constructor;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.*;

/**
 * A feature source which derives its information
 * from a command line cli_plugin
 * User: jacob
 * Date: 2012/05/01
 */
@XmlAccessorType(XmlAccessType.NONE)
public abstract class PluginSource<E extends Feature, D extends Feature> {

    private static Logger log = Logger.getLogger(PluginSource.class);

    /**
     * Initial command tokens. This will typically include both
     * the executable and command, e.g. {"/usr/bin/bedtools", "intersect"}
     */
    @XmlList
    @XmlAttribute
    protected List<String> commands;

    @XmlJavaTypeAdapter(MyMapAdapter.class)
    protected LinkedHashMap<Argument, Object> arguments;

    /**
     * We store the argument values by id for later retrieval by
     * other arguments, or the parser. For instance, if a parser
     * needs to know what the output file was.
     */

    @XmlElement
    protected PluginSpecReader.Parser parser;

    protected URL[] decodingLibURLs = new URL[0];

    @XmlAttribute
    protected String specPath = null;

    /**
     * For decoding, we may need to know how many columns
     * were written out in the first place
     */
    protected List<Map<String, Object>> attributes = new ArrayList<Map<String, Object>>(2);

    /**
     * Each time we output data, we give it a unique ID.
     * As yet
     */
    protected String lastRunId;
    private static final String RUN_ID_ATTR = "RUN_ID";

    //private QueryTracker queryTracker = new QueryTracker();

    @SubtlyImportant
    protected PluginSource() {
    }

    public PluginSource(List<String> commands, LinkedHashMap<Argument, Object> arguments, PluginSpecReader.Output outputAttrs, String specPath) {
        this.commands = commands;
        this.arguments = arguments;
        this.parser = outputAttrs.parser;
        this.specPath = specPath;

        String[] libs = this.parser.libs;
        libs = libs != null ? libs : new String[]{};
        try {
            decodingLibURLs = PluginSpecReader.getLibURLs(libs, FileUtils.getParent(specPath));
        } catch (MalformedURLException e) {
            log.error("Error parsing library URL", e);
            throw new RuntimeException(e);
        }
    }

    /**
     * Encode features into strings using {@link #getEncodingCodec(Argument)} and write them to the provided stream.
     * Stream will be closed after data written
     *
     * @param outputStream
     * @param features
     * @param argument
     * @return
     */
    protected final Map<String, Object> writeFeaturesToStream(OutputStream outputStream, Iterator features, Argument argument)
            throws IOException {

        Map<String, Object> attributes = null;
        if (features != null) {
            FeatureEncoder codec = getEncodingCodec(argument);
            attributes = codec.encodeAll(outputStream, features);
        }
        outputStream.flush();
        outputStream.close();

        return attributes;
    }

    protected final String[] genFullCommand(String chr, int start, int end, int zoom) throws IOException {

        List<String> fullCmd = new ArrayList<String>(commands);

        attributes.clear();

        String runId = createNewRunId();

        Map<String, String> idVariables = new HashMap<String, String>(arguments.size());
        idVariables.put(RUN_ID_ATTR, runId);

        for (Map.Entry<Argument, Object> entry : arguments.entrySet()) {
            Argument arg = entry.getKey();

            if (!arg.isValidValue(entry.getValue())) {
                String msg = "Type: " + arg.getType() + " value: " + entry.getValue();
                throw new IllegalArgumentException(msg);
            }

            String[] sVal = null;
            String ts = null;
            switch (arg.getType()) {
                case BOOL:
                    boolean selected = (Boolean) entry.getValue();
                    if (selected) {
                        //Output the cmd_arg, but it doesn't take an argument
                    } else {
                        continue;
                    }
                    break;
                case LONGTEXT:
                case TEXT:
                    ts = (String) entry.getValue();
                    if (ts == null || ts.trim().length() == 0) {
                        continue;
                    }
                    sVal = new String[]{ts};
                    if (arg.getType() == Argument.InputType.TEXT) {
                        sVal = ts.split("\\s+");
                    }
                    break;
                case ALIGNMENT_TRACK:
                    ts = createTempFile((AlignmentTrack) entry.getValue(), arg, chr, start, end, zoom);
                    sVal = new String[]{ts};
                    break;
                case VARIANT_TRACK:
                case FEATURE_TRACK:
                case DATA_TRACK:
                    ts = createTempFile((Track) entry.getValue(), arg, chr, start, end, zoom);
                    sVal = new String[]{ts};
                    break;
                case MULTI_FEATURE_TRACK:
                    sVal = createTempFiles((List<FeatureTrack>) entry.getValue(), arg, chr, start, end, zoom);
                    break;
                case LOCUS:
                    ts = writeLocus(arg, chr, start, end);
                    sVal = new String[]{ts};
                    break;
            }

            if (arg.getId() != null && sVal != null) {
                idVariables.put(arg.getId(), sVal[0]);
            }

            if (arg.isOutput()) {
                String cmdArg = arg.getCmdArg();
                if (cmdArg.trim().length() > 0) {
                    cmdArg = replaceStringsFromIds(cmdArg, idVariables);
                    fullCmd.add(cmdArg);
                }
                if (sVal != null) {
                    fullCmd.addAll(Arrays.asList(sVal));
                }
            }
        }

        parser.source = replaceStringsFromIds(parser.source, idVariables);

        return fullCmd.toArray(new String[0]);
    }

    protected String writeLocus(Argument arg, String chr, int start, int end) throws IOException{
        Feature feat = new SimpleBEDFeature(start, end, chr);
        return createTempFile(Arrays.asList(feat), arg);
    }

    /**
     * Replace strings of form $"variablename", similar
     * to how the unix shell deals with variables.
     * <p/>
     * This is best demonstrated by example.
     * inputString = "My name is $myname"
     * idVariables = {"myname" -> "bob", "othervariable" -> ted}
     * replaceStringsFromIds(inputString, idVariables) returns
     * "My name is bob"
     *
     * @param inputString String in which to perform replacement
     * @param idVariables May from string -> string, from to.
     * @return
     */
    private String replaceStringsFromIds(String inputString, Map<String, String> idVariables) {
        for (String argId : idVariables.keySet()) {
            inputString = inputString.replace("$" + argId, idVariables.get(argId));
        }
        return inputString;
    }

    protected String createNewRunId() {
        lastRunId = "" + System.currentTimeMillis();
        return lastRunId;
    }

    String getLastRunId() {
        return lastRunId;
    }

    /**
     * Convenience for calling {@link #createTempFile(org.broad.igv.track.Track, org.broad.igv.cli_plugin.Argument, String, int, int, int)};
     * on each track
     *
     * @param tracks
     * @param chr
     * @param start
     * @param end
     * @param zoom
     * @return
     * @throws java.io.IOException
     */
    private String[] createTempFiles(List<FeatureTrack> tracks, Argument argument, String chr, int start, int end, int zoom) throws IOException {
        String[] fileNames = new String[tracks.size()];
        int fi = 0;
        for (FeatureTrack track : tracks) {
            fileNames[fi++] = createTempFile(track, argument, chr, start, end, zoom);
        }
        return fileNames;
    }

    /**
     * Write out data from feature sources within the specified interval
     * to temporary files.
     *
     * @param track
     * @param chr
     * @param start
     * @param end
     * @return String with temp file name.
     * @throws java.io.IOException
     */
    protected abstract String createTempFile(Track track, Argument argument, String chr, int start, int end, int zoom) throws IOException;

    protected final String createTempFile(List features, Argument argument) throws IOException {
        String ext = ".tmp";
        switch(argument.getType()){
            case ALIGNMENT_TRACK:
                ext += ".sam";
                break;
            case LOCUS:
                ext += ".bed";
                break;
        }
        File outFile = File.createTempFile("features", ext, null);
        outFile.deleteOnExit();

        Map<String, Object> attributes = writeFeaturesToStream(new FileOutputStream(outFile), features.iterator(), argument);
        String path = outFile.getAbsolutePath();
        this.attributes.add(attributes);
        return path;
    }

    protected List<Alignment> getAlignmentsForRange(AlignmentTrack track, String chr, int start, int end, int zoom) throws IOException {

        Collection<AlignmentInterval> loadedIntervals = track.getDataManager().getAllLoadedIntervals();
        List<Alignment> alignments = new ArrayList<Alignment>();
        for (AlignmentInterval interval : loadedIntervals) {
            if (interval.overlaps(chr, start, end)) {
                Iterator<Alignment> iter = interval.getAlignmentIterator();
                while (iter.hasNext()) {
                    Alignment al = iter.next();
                    if (al.getStart() <= end && al.getEnd() >= start) {
                        alignments.add(al);
                    }
                }
            }
        }

        return alignments;
    }

    /**
     * Perform the actual combination operation between the constituent data
     * sources.
     *
     * @param chr
     * @param start
     * @param end
     * @return
     * @throws java.io.IOException
     */
    protected final Iterator<D> getFeatures(String chr, int start, int end, int zoom) throws IOException {
        if (parser.source == null) {
            throw new IllegalStateException("Null value for source");
        }

        boolean rerun = true;
        //synchronized (this.queryTracker){
        //    rerun = !this.queryTracker.isQuerySame(chr, start, end, zoom);
        //}

        /**
         * A process might generate multiple output files, we only want to run it once.
         * e.g. Cufflinks generates transcripts.gtf, genes.fpkm_tracking, isoforms.fpkm_tracking
         * If the file exists we just read from it
         */

        InputStream dataStream = null;
        if (!rerun) {
            File inFile = new File(parser.source);
            if (inFile.canRead()) {
                //Use the existing file
                dataStream = new FileInputStream(parser.source);
            }
        }

        if (dataStream == null) {
            //synchronized (this.queryTracker){
            String[] fullCmd = genFullCommand(chr, start, end, zoom);
            //log.debug("interval: " + Locus.getFormattedLocusString(chr, start, end));
            //log.debug(StringUtils.join(fullCmd, " "));

            //Start cli_plugin process
            Process pr = RuntimeUtils.startExternalProcess(fullCmd, null, null);

            if (parser.source.equals(PluginSpecReader.Parser.SOURCE_STDOUT)) {
                dataStream = pr.getInputStream();
            } else {
                try {
                    pr.waitFor();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                dataStream = new FileInputStream(parser.source);
            }
            //this.queryTracker.setLastQuery(chr, start, end, zoom);
            //}
        }
        //Read back in the data which cli_plugin output
        FeatureDecoder<D> codec = getDecodingCodec();

        return codec.decodeAll(dataStream, parser.strict);
    }

    /**
     * Create encoding codec, and apply inputs
     *
     * @param argument
     * @return
     */
    protected final FeatureEncoder<E> getEncodingCodec(Argument argument) {
        FeatureEncoder<E> codec = instantiateEncodingCodec(argument);
        codec.setInputs(Collections.unmodifiableList(commands), Collections.unmodifiableMap(arguments), argument);
        return codec;
    }

    /**
     * Get the encoding codec for this argument. Default
     * is IGVBEDCodec, if there was none specified.
     * <p/>
     * Codec will be reflectively instantiated if it was specified
     * in the {@code argument}
     *
     * @param argument
     * @return
     */
    private final FeatureEncoder instantiateEncodingCodec(Argument argument) {
        String encodingCodec = argument.getEncodingCodec();

        if (encodingCodec == null) {
            switch(argument.getType()){
                case LOCUS:
                case FEATURE_TRACK:
                case MULTI_FEATURE_TRACK:
                    return new AsciiEncoder(new IGVBEDCodec());
                case ALIGNMENT_TRACK:
                    return new SamAlignmentEncoder();
                case VARIANT_TRACK:
                    return new VCFEncoder();
            default:
                throw new IllegalArgumentException("No encoding codec provided and default not available");
            }
        }

        try {
            URL[] libURLs = PluginSpecReader.getLibURLs(argument.getLibPaths(), FileUtils.getParent(specPath));
            if (libURLs == null) libURLs = new URL[0];
            ClassLoader loader = URLClassLoader.newInstance(
                    libURLs, getClass().getClassLoader()
            );
            Class clazz = loader.loadClass(encodingCodec);
            Constructor constructor = clazz.getConstructor();
            Object ocodec = constructor.newInstance();
            FeatureEncoder<E> codec;
            if (!(ocodec instanceof FeatureEncoder) && ocodec instanceof LineFeatureEncoder) {
                codec = new AsciiEncoder((LineFeatureEncoder<E>) ocodec);
            } else {
                codec = (FeatureEncoder<E>) ocodec;
            }
            return codec;

        } catch (ClassNotFoundException e) {
            log.error("Could not find class " + encodingCodec, e);
            throw new IllegalArgumentException(e);
        } catch (MalformedURLException e) {
            log.error("Malformed library URL", e);
            throw new RuntimeException(e);
        } catch (Exception e) {
            log.error("Exception getting encoding codec", e);
            throw new RuntimeException(e);
        }
    }

    /**
     * Create decoding codec and set inputs and attributes
     *
     * @return
     */
    protected final FeatureDecoder<D> getDecodingCodec() {
        FeatureDecoder<D> codec = instantiateDecodingCodec(parser.decodingCodec, decodingLibURLs);
        codec.setInputs(Collections.unmodifiableList(commands), Collections.unmodifiableMap(arguments));
        codec.setAttributes(Collections.unmodifiableList(attributes));
        return codec;
    }

    /**
     * Instantiate decodingCodec, using {@code libURLs} as classpath. Will throw exceptions
     * if class cannot be instantiated
     *
     * @param decodingCodec
     * @param libURLs
     * @return
     */
    protected final FeatureDecoder<D> instantiateDecodingCodec(String decodingCodec, URL[] libURLs) {
        if (decodingCodec == null) {
            FeatureCodec<D, ?> knownCodec = CodecFactory.getCodec("." + parser.format, GenomeManager.getInstance().getCurrentGenome());
            if (knownCodec == null) {
                throw new IllegalArgumentException("Unable to find codec for format " + parser.format);
            } else if (knownCodec instanceof AsciiFeatureCodec) {
                return new AsciiDecoder.DecoderWrapper<D>((AsciiFeatureCodec) knownCodec);
            } else {
                return new FeatureCodecDecoder<D>((FeatureCodec<D, PositionalBufferedStream>) knownCodec);
            }
        }

        try {

            if (libURLs == null) libURLs = new URL[0];
            ClassLoader loader = URLClassLoader.newInstance(libURLs,
                    getClass().getClassLoader());

            Class clazz = loader.loadClass(decodingCodec);
            Constructor constructor = clazz.getConstructor();
            Object codec = constructor.newInstance();
            //User can pass in LineFeatureDecoder, we just wrap it
            if (!(codec instanceof FeatureDecoder) && codec instanceof LineFeatureDecoder) {
                return new AsciiDecoder((LineFeatureDecoder<D>) codec);
            }
            return (FeatureDecoder<D>) codec;

        } catch (ClassNotFoundException e) {
            log.error("Could not find class " + decodingCodec, e);
            throw new IllegalArgumentException(e);
        } catch (Exception e) {
            log.error("Exception getting decoding codec", e);
            throw new RuntimeException(e);
        }

    }


    public void getPersistentState() {
        /**
         * The structure here uses similar tags to the plugin XML spec, but is not exactly
         * the same. Because one and only one command was actually used we don't need
         * to nest as much. We also need to store argument values as well as inputs
         */

//        Map<String, String> parentProps = new HashMap<String, String>(5);

//        parentProps.put(DECODING_CODEC, parser.format);
//        //TODO Less ambiguous name to not clash with argument
//        parentProps.put(DECODING_LIBS, StringUtils.join(decodingLibURLs, ","));
//        parentProps.put(FORMAT, parser.format);
//        parentProps.put("specPath", specPath);
//
//        List<RecursiveAttributes> allChildren = new ArrayList<RecursiveAttributes>(4);
//
//
//        List<RecursiveAttributes> persCommands = new ArrayList<RecursiveAttributes>();
//        for(int ii=0; ii < commands.size(); ii++){
//            String command = commands.get(ii);
//            Map<String, String> props = new HashMap<String, String>(1);
//            props.put("value", command);
//            props.put("index", "" + ii);
//            RecursiveAttributes persCommand = new RecursiveAttributes(PluginSpecReader.CMD_ARG, props);
//            persCommands.add(persCommand);
//        }
//        RecursiveAttributes persCommandParent = new RecursiveAttributes(PluginSpecReader.COMMAND, Collections.<String, String>emptyMap(),
//                persCommands);
//
//
//        allChildren.add(persCommandParent);
//
//        for(Map.Entry<Object, Object> entry: arguments.entrySet()){
//            Argument argument = (Argument) entry.getKey();
//            List<String> values = null;
//            String sval;
//            switch (argument.getType()) {
//                case MULTI_FEATURE_TRACK:
//                    List<FeatureTrack> lVal = (List<FeatureTrack>) entry.getValue();
//                    values = new ArrayList<String>(lVal.size());
//                    for(FeatureTrack fTrack: lVal){
//                        values.add(fTrack.getId());
//                    }
//                    break;
//                case LONGTEXT:
//                case TEXT:
//                    sval = (String) entry.getValue();
//                    values = Arrays.asList(sval);
//                    break;
//                case FEATURE_TRACK:
//                case DATA_TRACK:
//                case ALIGNMENT_TRACK:
//                    sval = ((Track) entry.getValue()).getId();
//                    values = Arrays.asList(sval);
//                    break;
//            }
//
//            if(values == null) continue;
//
//            //Generate list of values for the argument
//            List<RecursiveAttributes> persValues = new ArrayList<RecursiveAttributes>();
//            for(String value: values){
//                Map<String, String> cprop = new HashMap<String, String>(1);
//                cprop.put(VALUE, value);
//                persValues.add(new RecursiveAttributes(VALUE, cprop));
//            }
//
//            //Group values together under relevant argument
//            //RecursiveAttributes persArgument = argument.getPersistentState();
//            //persArgument.getChildren().addAll(persValues);
//
//            //Arguments aren't grouped together, just put flat in the children of the highest level
//            //allChildren.add(persArgument);
//        }
//
//        RecursiveAttributes overall = new RecursiveAttributes(getClass().getName(), parentProps, allChildren);
//        return overall;
    }

    public void updateTrackReferences(List<Track> allTracks) {
        MyMapAdapter.updateTrackReferences(arguments, allTracks);
    }

//    public void setQueryTracker(QueryTracker queryTracker){
//        this.queryTracker = queryTracker;
//    }

    static class XmlMap {
        public List<Argument> arg =
                new ArrayList<Argument>();
    }

    public final static class MyMapAdapter extends XmlAdapter<XmlMap, LinkedHashMap<Argument, Object>> {

        @Override
        public LinkedHashMap<Argument, Object> unmarshal(XmlMap v) throws Exception {
            LinkedHashMap<Argument, Object> argumentMap = new LinkedHashMap(v.arg.size());
            for (Argument argument : v.arg) {
                Object oVal = null;
                switch (argument.getType()) {
                    case BOOL:
                        oVal = Boolean.parseBoolean(argument.value.get(0));
                        break;
                    case LONGTEXT:
                    case TEXT:
                        oVal = argument.value.get(0);
                        break;
                    case MULTI_FEATURE_TRACK:
                    case FEATURE_TRACK:
                    case DATA_TRACK:
                    case ALIGNMENT_TRACK:
                        oVal = findTrackReference(argument, null);
                        break;
                }
                argumentMap.put(argument, oVal);
            }
            return argumentMap;
        }

        private static Object findTrackReference(Argument argument, List<Track> allTracks) {
            Object oVal = null;
            switch (argument.getType()) {
                case MULTI_FEATURE_TRACK:
                    List<FeatureTrack> inputTracks = new ArrayList<FeatureTrack>(argument.value.size());

                    for (String trackId : argument.value) {
                        inputTracks.add((FeatureTrack) IGVSessionReader.getMatchingTrack(trackId, allTracks));
                    }
                    oVal = inputTracks;
                    break;
                case FEATURE_TRACK:
                case DATA_TRACK:
                case ALIGNMENT_TRACK:
                    String trackId = argument.value.get(0);
                    oVal = IGVSessionReader.getMatchingTrack(trackId, allTracks);
                    break;
            }
            return oVal;
        }

        public static void updateTrackReferences(Map<Argument, Object> argumentMap, List<Track> allTracks) {
            for (Argument argument : argumentMap.keySet()) {
                //Reference already resolved
                if (argumentMap.get(argument) != null) continue;
                Object oVal = findTrackReference(argument, allTracks);
                argumentMap.put(argument, oVal);
            }
        }

        @Override
        public XmlMap marshal(LinkedHashMap<Argument, Object> v) throws Exception {
            XmlMap outMap = new XmlMap();
            for (Map.Entry<Argument, Object> loopEntry : v.entrySet()) {
                Argument argument = loopEntry.getKey();
                List<String> values = null;
                String sval;
                switch (argument.getType()) {
                    case MULTI_FEATURE_TRACK:
                        List<FeatureTrack> lVal = (List<FeatureTrack>) loopEntry.getValue();
                        values = new ArrayList<String>(lVal.size());
                        for (FeatureTrack fTrack : lVal) {
                            values.add(fTrack.getId());
                        }
                        break;
                    case BOOL:
                        sval = Boolean.toString((Boolean) loopEntry.getValue());
                        values = Arrays.asList(sval);
                        break;
                    case LONGTEXT:
                    case TEXT:
                        sval = (String) loopEntry.getValue();
                        values = Arrays.asList(sval);
                        break;
                    case FEATURE_TRACK:
                    case DATA_TRACK:
                    case ALIGNMENT_TRACK:
                        sval = ((Track) loopEntry.getValue()).getId();
                        values = Arrays.asList(sval);
                        break;
                }

                argument.value = values;
                outMap.arg.add(argument);
            }
            return outMap;
        }

    }


}
