/*
 * Copyright 2013, Google Inc.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package org.jf.dexlib2.util;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

import org.jf.util.ExceptionWithContext;
import org.jf.util.Hex;
import org.jf.util.TwoColumnOutput;

import java.io.IOException;
import java.io.Writer;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

/**
 * Collects/presents a set of textual annotations, each associated with a range of bytes or a specific point
 * between bytes.
 * <p>
 * Point annotations cannot occur within the middle of a range annotation, only at the endpoints, or some other area
 * with no range annotation.
 * <p>
 * Multiple point annotations can be defined for a given point. They will be printed in insertion order.
 * <p>
 * Only a single range annotation may exist for any given range of bytes. Range annotations may not overlap.
 */
public class AnnotatedBytes {
    /**
     * This defines the bytes ranges and their associated range and point annotations.
     * <p>
     * A range is defined by 2 consecutive keys in the map. The first key is the inclusive start point, the second key
     * is the exclusive end point. The range annotation for a range is associated with the first key for that range.
     * The point annotations for a point are associated with the key at that point.
     */
    @NonNull
    private TreeMap<Integer, AnnotationEndpoint> annotatations = Maps.newTreeMap();

    private int cursor;
    private int indentLevel;

    /**
     * &gt;= 40 (if used); the desired maximum output width
     */
    private int outputWidth;

    /**
     * &gt;= 8 (if used); the number of bytes of hex output to use
     * in annotations
     */
    private int hexCols = 8;

    private int startLimit = -1;
    private int endLimit = -1;

    public AnnotatedBytes(int width) {
        this.outputWidth = width;
    }

    /**
     * Moves the cursor to a new location
     *
     * @param offset The offset to move to
     */
    public void moveTo(int offset) {
        cursor = offset;
    }

    /**
     * Moves the cursor forward or backward by some amount
     *
     * @param offset The amount to move the cursor
     */
    public void moveBy(int offset) {
        cursor += offset;
    }

    public void annotateTo(int offset, @NonNull String msg, Object... formatArgs) {
        annotate(offset - cursor, msg, formatArgs);
    }

    /**
     * Add an annotation of the given length at the current location.
     * <p>
     * The location
     *
     * @param length     the length of data being annotated
     * @param msg        the annotation message
     * @param formatArgs format arguments to pass to String.format
     */
    public void annotate(int length, @NonNull String msg, Object... formatArgs) {
        if (startLimit != -1 && endLimit != -1 && (cursor < startLimit || cursor >= endLimit)) {
            throw new ExceptionWithContext("Annotating outside the parent bounds");
        }

        String formattedMsg;
        if (formatArgs != null && formatArgs.length > 0) {
            formattedMsg = String.format(msg, formatArgs);
        } else {
            formattedMsg = msg;
        }
        int exclusiveEndOffset = cursor + length;

        AnnotationEndpoint endPoint = null;

        // Do we have an endpoint at the beginning of this annotation already?
        AnnotationEndpoint startPoint = annotatations.get(cursor);
        if (startPoint == null) {
            // Nope. We need to check that we're not in the middle of an existing range annotation.
            Map.Entry<Integer, AnnotationEndpoint> previousEntry = annotatations.lowerEntry(cursor);
            if (previousEntry != null) {
                AnnotationEndpoint previousAnnotations = previousEntry.getValue();
                AnnotationItem previousRangeAnnotation = previousAnnotations.rangeAnnotation;
                if (previousRangeAnnotation != null) {
                    throw new ExceptionWithContext(
                            "Cannot add annotation %s, due to existing annotation %s",
                            formatAnnotation(cursor, cursor + length, formattedMsg),
                            formatAnnotation(previousEntry.getKey(),
                                    previousRangeAnnotation.annotation));
                }
            }
        } else if (length > 0) {
            AnnotationItem existingRangeAnnotation = startPoint.rangeAnnotation;
            if (existingRangeAnnotation != null) {
                throw new ExceptionWithContext(
                        "Cannot add annotation %s, due to existing annotation %s",
                        formatAnnotation(cursor, cursor + length, formattedMsg),
                        formatAnnotation(cursor, existingRangeAnnotation.annotation));
            }
        }

        if (length > 0) {
            // Ensure that there is no later annotation that would intersect with this one
            Map.Entry<Integer, AnnotationEndpoint> nextEntry = annotatations.higherEntry(cursor);
            if (nextEntry != null) {
                int nextKey = nextEntry.getKey();
                if (nextKey < exclusiveEndOffset) {
                    // there is an endpoint that would intersect with this annotation. Find one of the annotations
                    // associated with the endpoint, to print in the error message
                    AnnotationEndpoint nextEndpoint = nextEntry.getValue();
                    AnnotationItem nextRangeAnnotation = nextEndpoint.rangeAnnotation;
                    if (nextRangeAnnotation != null) {
                        throw new ExceptionWithContext(
                                "Cannot add annotation %s, due to existing annotation %s",
                                formatAnnotation(cursor, cursor + length, formattedMsg),
                                formatAnnotation(nextKey, nextRangeAnnotation.annotation));
                    }
                    if (nextEndpoint.pointAnnotations.size() > 0) {
                        throw new ExceptionWithContext(
                                "Cannot add annotation %s, due to existing annotation %s",
                                formatAnnotation(cursor, cursor + length, formattedMsg),
                                formatAnnotation(nextKey, nextKey,
                                        nextEndpoint.pointAnnotations.get(0).annotation));
                    }
                    // There are no annotations on this endpoint. This "shouldn't" happen. We can still throw an exception.
                    throw new ExceptionWithContext(
                            "Cannot add annotation %s, due to existing annotation endpoint at %d",
                            formatAnnotation(cursor, cursor + length, formattedMsg),
                            nextKey);
                }

                if (nextKey == exclusiveEndOffset) {
                    // the next endpoint matches the end of the annotation we are adding
                    endPoint = nextEntry.getValue();
                }
            }
        }

        // Now, actually add the annotation
        // If startPoint is null, we need to create a new one and add it to annotations. Otherwise, we just need to add
        // the annotation to the existing AnnotationEndpoint
        // the range annotation
        if (startPoint == null) {
            startPoint = new AnnotationEndpoint();
            annotatations.put(cursor, startPoint);
        }
        if (length == 0) {
            startPoint.pointAnnotations.add(new AnnotationItem(indentLevel, formattedMsg));
        } else {
            startPoint.rangeAnnotation = new AnnotationItem(indentLevel, formattedMsg);

            // If endPoint is null, we need to create a new, empty one and add it to annotations
            if (endPoint == null) {
                endPoint = new AnnotationEndpoint();
                annotatations.put(exclusiveEndOffset, endPoint);
            }
        }

        cursor += length;
    }

    private String formatAnnotation(int offset, String annotationMsg) {
        Integer endOffset = annotatations.higherKey(offset);
        return formatAnnotation(offset, endOffset, annotationMsg);
    }

    private String formatAnnotation(int offset, Integer endOffset, String annotationMsg) {
        if (endOffset != null) {
            return String.format("[0x%x, 0x%x) \"%s\"", offset, endOffset, annotationMsg);
        } else {
            return String.format("[0x%x, ) \"%s\"", offset, annotationMsg);
        }
    }

    public void indent() {
        indentLevel++;
    }

    public void deindent() {
        indentLevel--;
        if (indentLevel < 0) {
            indentLevel = 0;
        }
    }

    public int getCursor() {
        return cursor;
    }

    private static class AnnotationEndpoint {
        /**
         * Annotations that are associated with a specific point between bytes
         */
        @NonNull
        public final List<AnnotationItem> pointAnnotations = Lists.newArrayList();
        /**
         * Annotations that are associated with a range of bytes
         */
        @Nullable
        public AnnotationItem rangeAnnotation = null;
    }

    private static class AnnotationItem {
        public final int indentLevel;
        public final String annotation;

        public AnnotationItem(int indentLevel, String annotation) {
            this.indentLevel = indentLevel;
            this.annotation = annotation;
        }
    }

    /**
     * @return The width of the right side containing the annotations
     */
    public int getAnnotationWidth() {
        int leftWidth = 8 + (hexCols * 2) + (hexCols / 2);

        return outputWidth - leftWidth;
    }

    /**
     * Writes the annotated content of this instance to the given writer.
     *
     * @param out non-null; where to write to
     */
    public void writeAnnotations(Writer out, byte[] data) throws IOException {
        int rightWidth = getAnnotationWidth();
        int leftWidth = outputWidth - rightWidth - 1;

        String padding = Strings.repeat(" ", 1000);

        TwoColumnOutput twoc = new TwoColumnOutput(out, leftWidth, rightWidth, "|");

        Integer[] keys = new Integer[annotatations.size()];
        keys = annotatations.keySet().toArray(keys);

        AnnotationEndpoint[] values = new AnnotationEndpoint[annotatations.size()];
        values = annotatations.values().toArray(values);

        for (int i = 0; i < keys.length - 1; i++) {
            int rangeStart = keys[i];
            int rangeEnd = keys[i + 1];

            AnnotationEndpoint annotations = values[i];

            for (AnnotationItem pointAnnotation : annotations.pointAnnotations) {
                String paddingSub = padding.substring(0, pointAnnotation.indentLevel * 2);
                twoc.write("", paddingSub + pointAnnotation.annotation);
            }

            String right;
            AnnotationItem rangeAnnotation = annotations.rangeAnnotation;
            if (rangeAnnotation != null) {
                right = padding.substring(0, rangeAnnotation.indentLevel * 2);
                right += rangeAnnotation.annotation;
            } else {
                right = "";
            }

            String left = Hex.dump(data, rangeStart, rangeEnd - rangeStart, rangeStart, hexCols, 6);

            twoc.write(left, right);
        }

        int lastKey = keys[keys.length - 1];
        if (lastKey < data.length) {
            String left = Hex.dump(data, lastKey, data.length - lastKey, lastKey, hexCols, 6);
            twoc.write(left, "");
        }
    }

    public void setLimit(int start, int end) {
        this.startLimit = start;
        this.endLimit = end;
    }

    public void clearLimit() {
        this.startLimit = -1;
        this.endLimit = -1;
    }
}