/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.tools.pixelprobe.decoder.psd;

import com.android.tools.pixelprobe.BlendMode;
import com.android.tools.pixelprobe.ShapeInfo;
import com.android.tools.pixelprobe.ShapeInfo.Path;
import com.android.tools.pixelprobe.ShapeInfo.PathType;
import com.android.tools.pixelprobe.color.Colors;
import com.android.tools.pixelprobe.decoder.psd.PsdFile.ColorProfileBlock;
import com.android.tools.pixelprobe.decoder.psd.PsdFile.Descriptor;
import com.android.tools.pixelprobe.decoder.psd.PsdFile.DescriptorItem.UnitDouble;
import com.android.tools.pixelprobe.decoder.psd.PsdFile.PathRecord;
import com.android.tools.pixelprobe.decoder.psd.PsdFile.ShapeMask;
import com.android.tools.pixelprobe.util.Bytes;

import java.awt.*;
import java.awt.color.ColorSpace;
import java.awt.color.ICC_ColorSpace;
import java.awt.color.ICC_Profile;
import java.awt.geom.AffineTransform;
import java.awt.geom.Path2D;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.android.tools.pixelprobe.decoder.psd.PsdFile.PathRecord.*;

/**
 * Various utilities to decode PSD file data chunks.
 */
@SuppressWarnings("UseJBColor")
final class PsdUtils {
    /**
     * Constant to convert centimeters to inches.
     */
    static final float CENTIMETER_TO_INCH = 1.0f / 2.54f;
    /**
     * Constant to convert millimeters to inches.
     */
    static final float MILLIMETER_TO_INCH = 1.0f / 25.4f;

    // Used to parse descriptor paths
    private static final Pattern PATH_PATTERN = Pattern.compile("([a-zA-Z0-9]+)\\[([0-9]+)\\]");

    // List of all the blending modes supported by Photoshop. They are all identified
    // by a 4 character key. The BlendMode enum uses Photoshop's naming conventions
    // (linear dodge is for instance commonly known as "add").
    private static final Map<String, BlendMode> blendModes = new HashMap<>();
    static {
        blendModes.put("pass", BlendMode.PASS_THROUGH);
        blendModes.put("norm", BlendMode.NORMAL);
        blendModes.put("diss", BlendMode.DISSOLVE);
        blendModes.put("dark", BlendMode.DARKEN);
        blendModes.put("mul ", BlendMode.MULTIPLY);
        blendModes.put("idiv", BlendMode.COLOR_BURN);
        blendModes.put("lbrn", BlendMode.LINEAR_BURN);
        blendModes.put("dkCl", BlendMode.DARKER_COLOR);
        blendModes.put("lite", BlendMode.LIGHTEN);
        blendModes.put("scrn", BlendMode.SCREEN);
        blendModes.put("div ", BlendMode.COLOR_DODGE);
        blendModes.put("lddg", BlendMode.LINEAR_DODGE);
        blendModes.put("lgCl", BlendMode.LIGHTER_COLOR);
        blendModes.put("over", BlendMode.OVERLAY);
        blendModes.put("sLit", BlendMode.SOFT_LIGHT);
        blendModes.put("hLit", BlendMode.HARD_LIGHT);
        blendModes.put("vLit", BlendMode.VIVID_LIGHT);
        blendModes.put("lLit", BlendMode.LINEAR_LIGHT);
        blendModes.put("pLit", BlendMode.PIN_LIGHT);
        blendModes.put("hMix", BlendMode.HARD_MIX);
        blendModes.put("diff", BlendMode.DIFFERENCE);
        blendModes.put("smud", BlendMode.EXCLUSION);
        blendModes.put("fsub", BlendMode.SUBTRACT);
        blendModes.put("fdiv", BlendMode.DIVIDE);
        blendModes.put("hue ", BlendMode.HUE);
        blendModes.put("sat ", BlendMode.SATURATION);
        blendModes.put("colr", BlendMode.COLOR);
        blendModes.put("lum ", BlendMode.LUMINOSITY);
        // blend modes stored in descriptors have different keys
        blendModes.put("Nrml", BlendMode.NORMAL);
        blendModes.put("Dslv", BlendMode.DISSOLVE);
        blendModes.put("Drkn", BlendMode.DARKEN);
        blendModes.put("Mltp", BlendMode.MULTIPLY);
        blendModes.put("CBrn", BlendMode.COLOR_BURN);
        blendModes.put("linearBurn", BlendMode.LINEAR_BURN);
        blendModes.put("darkerColor", BlendMode.DARKER_COLOR);
        blendModes.put("Lghn", BlendMode.LIGHTEN);
        blendModes.put("Scrn", BlendMode.SCREEN);
        blendModes.put("CDdg", BlendMode.COLOR_DODGE);
        blendModes.put("linearDodge", BlendMode.LINEAR_DODGE);
        blendModes.put("lighterColor", BlendMode.LIGHTER_COLOR);
        blendModes.put("Ovrl", BlendMode.OVERLAY);
        blendModes.put("SftL", BlendMode.SOFT_LIGHT);
        blendModes.put("HrdL", BlendMode.HARD_LIGHT);
        blendModes.put("vividLight", BlendMode.VIVID_LIGHT);
        blendModes.put("linearLight", BlendMode.LINEAR_LIGHT);
        blendModes.put("pinLight", BlendMode.PIN_LIGHT);
        blendModes.put("hardMix", BlendMode.HARD_MIX);
        blendModes.put("Dfrn", BlendMode.DIFFERENCE);
        blendModes.put("Xclu", BlendMode.EXCLUSION);
        blendModes.put("blendSubtraction", BlendMode.SUBTRACT);
        blendModes.put("blendDivide", BlendMode.DIVIDE);
        blendModes.put("H   ", BlendMode.HUE);
        blendModes.put("Strt", BlendMode.SATURATION);
        blendModes.put("Clr ", BlendMode.COLOR);
        blendModes.put("Lmns", BlendMode.LUMINOSITY);
    }

    private static final Set<String> adjustmentLayers = new HashSet<>(20);
    static {
        adjustmentLayers.add("SoCo");
        adjustmentLayers.add("GdFl");
        adjustmentLayers.add("PtFl");
        adjustmentLayers.add("brit");
        adjustmentLayers.add("levl");
        adjustmentLayers.add("curv");
        adjustmentLayers.add("expA");
        adjustmentLayers.add("vibA");
        adjustmentLayers.add("hue ");
        adjustmentLayers.add("hue2");
        adjustmentLayers.add("blnc");
        adjustmentLayers.add("blwh");
        adjustmentLayers.add("phfl");
        adjustmentLayers.add("mixr");
        adjustmentLayers.add("clrL");
        adjustmentLayers.add("nvrt");
        adjustmentLayers.add("post");
        adjustmentLayers.add("thrs");
        adjustmentLayers.add("grdm");
        adjustmentLayers.add("selc");
    }

    private PsdUtils() {
    }

    /**
     * Finds a descriptor value in the specified descriptor.
     * The path must be of the form <pre>child.value</pre>, with an
     * optional index for descriptor arrays. For instance:
     *
     * <pre>
     * children.properties[2].key
     * </pre>
     *
     * @param descriptor The descriptor on which to apply the path
     * @param path The path of the descriptor value
     *
     * @return The value pointed to by the specified path or null
     *         if the path is invalid
     */
    @SuppressWarnings("unchecked")
    static <T> T get(Descriptor descriptor, String path) {
        Object result = null;
        Descriptor currentDescriptor = descriptor;

        String[] elements = path.split("\\.");
        for (String element : elements) {
            int index = -1;
            Matcher matcher = PATH_PATTERN.matcher(element);
            if (matcher.matches()) {
                element = matcher.group(1);
                index = Integer.parseInt(matcher.group(2));
            }

            PsdFile.DescriptorItem item = currentDescriptor.items.get(element);
            if (item == null) break;

            Object data = item.value.data;
            if (data == null) break;

            if (data instanceof PsdFile.DescriptorItem.ValueList) {
                if (index >= 0) {
                    data = ((PsdFile.DescriptorItem.ValueList) data).items.get(index).data;
                }
            } else if (data instanceof PsdFile.DescriptorItem.Reference) {
                if (index >= 0) {
                    data = ((PsdFile.DescriptorItem.Reference) data).items.get(index).data;
                }
            }

            if (data instanceof Descriptor) {
                result = currentDescriptor = (Descriptor) data;
            } else if (data instanceof PsdFile.FixedString ||
                       data instanceof PsdFile.UnicodeString ||
                       data instanceof PsdFile.DescriptorItem.Enumerated) {
                result = data.toString();
            } else if (data instanceof PsdFile.FixedByteArray) {
                result = ((PsdFile.FixedByteArray) data).value;
            } else {
                result = data;
            }
        }

        return (T) result;
    }

    static float getFloat(Descriptor descriptor, String path) {
        return ((Double) get(descriptor, path)).floatValue();
    }

    static float getFloat(Descriptor descriptor, String path, float defaultValue) {
        Double v = get(descriptor, path);
        return v == null ? defaultValue : v.floatValue();
    }

    static float getUnitFloat(Descriptor descriptor, String path, float resolution) {
        return (float) resolveUnit(get(descriptor, path), resolution);
    }

    static double resolveUnit(UnitDouble unitDouble, float resolution) {
        if (UnitDouble.PIXELS.equals(unitDouble.unit)) {
            return unitDouble.value;
        } else if (UnitDouble.POINTS.equals(unitDouble.unit)) {
            return unitDouble.value * resolution / 72.0;
        } else if (UnitDouble.INCHES.equals(unitDouble.unit)) {
            return unitDouble.value * resolution;
        } else if (UnitDouble.MILLIMETERS.equals(unitDouble.unit)) {
            return unitDouble.value * MILLIMETER_TO_INCH * resolution;
        } else if (UnitDouble.CENTIMETERS.equals(unitDouble.unit)) {
            return unitDouble.value * CENTIMETER_TO_INCH * resolution;
        } else if (UnitDouble.PERCENT.equals(unitDouble.unit)) {
            return unitDouble.value / 100.0;
        } else if (UnitDouble.ANGLE_DEGREES.equals(unitDouble.unit)) {
            return unitDouble.value / 360.0;
        }
        return unitDouble.value;
    }

    static boolean getBoolean(Descriptor descriptor, String path) {
        Object value = get(descriptor, path);
        if (value instanceof Boolean) {
            return (Boolean) value;
        } else if (value == null) {
            return true;
        }
        return "true".equalsIgnoreCase(String.valueOf(value));
    }

    /**
     * Returns the blend mode associated with the key found
     * in a PSD file.
     *
     * @param mode Blending mode key as found in a PSD file
     *
     * @return The blend mode matching the specified key, or
     * {@link BlendMode#NORMAL} if the key is unknown
     */
    static BlendMode getBlendMode(String mode) {
        BlendMode blendMode = blendModes.get(mode);
        return blendMode != null ? blendMode : BlendMode.NORMAL;
    }

    /**
     * Returns the known list of adjustment layer keys.
     */
    static Set<String> getAdjustmentLayerKeys() {
        return adjustmentLayers;
    }

    /**
     * Creates a color space from a color profile block. Can return null
     * if the specified block is null.
     */
    static ColorSpace createColorSpace(ColorProfileBlock block) {
        if (block == null) return null;
        ICC_Profile iccProfile = ICC_Profile.getInstance(block.icc);
        return new ICC_ColorSpace(iccProfile);
    }

    /**
     * Returns the image resource block that matches the specified id.
     * Can return null if the block cannot be found.
     */
    @SuppressWarnings("unchecked")
    static <T> T get(PsdFile.ImageResources resources, int id) {
        PsdFile.ImageResourceBlock block = resources.blocks.get(id);
        return block == null ? null : (T) block.data;
    }

    /**
     * Returns the color present in the specified descriptor.
     * The returned color always has an alpha of 1.0f.
     * If no color can be found, this method returns black.
     */
    static Color getColor(Descriptor descriptor) {
        return getColor(descriptor, 1.0f);
    }

    /**
     * Returns the color present in the specified descriptor.
     * If no color can be found, this method returns black.
     */
    static Color getColor(Descriptor descriptor, float alpha) {
        Descriptor color = get(descriptor, "Clr ");
        if (color == null) return Color.BLACK;

        String colorType = color.classId.toString();
        switch (colorType) {
            case Descriptor.CLASS_ID_COLOR_RGB:
                return colorFromRgb(color, alpha);
            case Descriptor.CLASS_ID_COLOR_HSB:
                return colorFromHsb(color, alpha);
            case Descriptor.CLASS_ID_COLOR_CMYK:
                return colorFromCmyk(color, alpha);
            case Descriptor.CLASS_ID_COLOR_LAB:
                return colorFromLab(color, alpha);
            case Descriptor.CLASS_ID_COLOR_GRAY:
                return colorFromGray(color, alpha);
        }

        if (alpha == 1.0f) {
            return Color.BLACK;
        }
        return new Color(0.0f, 0.0f, 0.0f, alpha);
    }

    private static Color colorFromRgb(Descriptor color, float alpha) {
        return new Color(
            getFloat(color, "Rd  ") / 255.0f,
            getFloat(color, "Grn ") / 255.0f,
            getFloat(color, "Bl  ") / 255.0f,
            alpha);
    }

    private static Color colorFromHsb(Descriptor color, float alpha) {
        float[] rgb = Colors.hsbToRgb(
            getUnitFloat(color, "H   ", 0.0f),
            getFloat(color, "Strt") / 100.0f,
            getFloat(color, "Brgh") / 100.0f);
        return new Color(rgb[0], rgb[1], rgb[2], alpha);
    }

    private static Color colorFromCmyk(Descriptor color, float alpha) {
        float[] rgb = Colors.getCmykColorSpace().toRGB(new float[] {
            getFloat(color, "Cyn "),
            getFloat(color, "Mgnt"),
            getFloat(color, "Ylw "),
            getFloat(color, "Blck")
        });
        return new Color(rgb[0], rgb[1], rgb[2], alpha);
    }

    private static Color colorFromLab(Descriptor color, float alpha) {
        float[] rgb = Colors.getLabColorSpace().toRGB(new float[] {
            getFloat(color, "Lmnc"),
            getFloat(color, "A   "),
            getFloat(color, "B   ")
        });
        return new Color(rgb[0], rgb[1], rgb[2], alpha);
    }

    private static Color colorFromGray(Descriptor color, float alpha) {
        float gray = Colors.linearRgbToRgb(getFloat(color, "Gry ") / 255.0f);
        return new Color(gray, gray, gray, alpha);
    }

    /**
     * Creates one or more path from a {@link PsdFile.ShapeMask} and add them to the
     * specified shape info.
     */
    static void createPaths(ShapeMask mask, ShapeInfo.Builder shapeInfo, AffineTransform transform) {
        Path2D.Float path = null;
        ShapeInfo.PathOp op = ShapeInfo.PathOp.ADD;

        // Each Bézier knot in a PSD is made of three points:
        //   - the anchor (the knot or point itself)
        //   - the control point before the anchor
        //   - the control point after the anchor
        //
        // PSD Bézier knots must be converted to moveTo/curveTo commands.
        // A curveTo() describes a cubic curve. To generate a curveTo() we
        // need three points:
        //   - the next anchor (the destination point of the curveTo())
        //   - the control point after the previous anchor
        //   - the control point before the next anchor

        PathType type = PathType.UNKNOWN;
        BezierKnot firstKnot = null;
        BezierKnot lastKnot = null;
        int moveCount = 0;

        for (PathRecord record : mask.pathRecords) {
            switch (record.selector) {
                // A "LENGTH" record marks the beginning of a new sub-path
                // Closed subpath needs special handling at the end
                case CLOSED_SUBPATH_LENGTH:
                case OPEN_SUBPATH_LENGTH:
                    if (type == PathType.CLOSED) {
                        // If the previous subpath is of the closed type, close it now
                        addToPath(path, firstKnot, lastKnot);
                        path.closePath();
                    }

                    SubPath subPath = (SubPath) record.data;
                    if (subPath.tag != SubPath.NO_OP) {
                        if (path != null) {
                            path.transform(transform);
                            if (moveCount > 1) type = PathType.UNKNOWN;
                            shapeInfo.addPath(new Path.Builder().path(path).op(op).type(type).build());
                        }

                        op = getPathOp(subPath.op);
                        path = new Path2D.Float(Path2D.WIND_EVEN_ODD, subPath.knotCount);
                        moveCount = 0;
                    } else if (path == null) {
                        op = ShapeInfo.PathOp.ADD;
                        path = new Path2D.Float(Path2D.WIND_EVEN_ODD, subPath.knotCount);
                        moveCount = 0;
                    }

                    type = record.selector == OPEN_SUBPATH_LENGTH ? PathType.OPEN : PathType.CLOSED;
                    firstKnot = lastKnot = null;
                    break;
                // Open and closed subpath knots can be handled the same way
                // The linked/unlinked characteristic only matters to interactive
                // editors and we happily throw away that information
                case CLOSED_SUBPATH_KNOT_LINKED:
                case CLOSED_SUBPATH_KNOT_UNLINKED:
                case OPEN_SUBPATH_KNOT_LINKED:
                case OPEN_SUBPATH_KNOT_UNLINKED:
                    if (path == null) continue;
                    BezierKnot knot = (BezierKnot) record.data;
                    if (lastKnot == null) {
                        // If we just started a subpath we need to insert a moveTo()
                        // using the new anchor
                        path.moveTo(
                                Bytes.fixed8_24ToFloat(knot.anchorX),
                                Bytes.fixed8_24ToFloat(knot.anchorY));
                        firstKnot = knot;
                        moveCount++;
                    } else {
                        // Otherwise let's curve to the new anchor
                        addToPath(path, knot, lastKnot);
                    }
                    lastKnot = knot;
                    break;
            }
        }

        // Close the subpath if needed
        if (type == PathType.CLOSED) {
            addToPath(path, firstKnot, lastKnot);
            path.closePath();
        }

        if (path != null) {
            path.transform(transform);
            if (moveCount > 1) type = PathType.UNKNOWN;
            shapeInfo.addPath(new Path.Builder().path(path).op(op).type(type).build());
        }
    }

    private static ShapeInfo.PathOp getPathOp(int op) {
        switch (op) {
            case SubPath.OP_XOR:
                return ShapeInfo.PathOp.EXCLUSIVE_OR;
            case SubPath.OP_MERGE:
                return ShapeInfo.PathOp.ADD;
            case SubPath.OP_SUBTRACT:
                return ShapeInfo.PathOp.SUBTRACT;
            case SubPath.OP_INTERSECT:
                return ShapeInfo.PathOp.INTERSECT;
        }
        return ShapeInfo.PathOp.ADD;
    }

    private static void addToPath(Path2D.Float path, BezierKnot toKnot, BezierKnot lastKnot) {
        float fromX = Bytes.fixed8_24ToFloat(lastKnot.anchorX);
        float fromY = Bytes.fixed8_24ToFloat(lastKnot.anchorY);
        float exitX = Bytes.fixed8_24ToFloat(lastKnot.controlExitX);
        float exitY = Bytes.fixed8_24ToFloat(lastKnot.controlExitY);
        float enterX = Bytes.fixed8_24ToFloat(toKnot.controlEnterX);
        float enterY = Bytes.fixed8_24ToFloat(toKnot.controlEnterY);
        float toX = Bytes.fixed8_24ToFloat(toKnot.anchorX);
        float toY = Bytes.fixed8_24ToFloat(toKnot.anchorY);

        if (exitX == fromX && exitY == fromY && enterX == toX && enterY == toY) {
            path.lineTo(toX, toY);
        } else {
            path.curveTo(exitX, exitY, enterX, enterY, toX, toY);
        }
    }
}
