package com.github.amarcruz.rntextsize;

import android.content.res.AssetManager;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.os.Build;
import android.text.BoringLayout;
import android.text.Layout;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.StaticLayout;
import android.text.TextPaint;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.DisplayMetricsHolder;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

class RNTextSizeModule extends ReactContextBaseJavaModule {
    private static final String TAG = "RNTextSize";
    private static final float SPACING_ADDITION = 0f;
    private static final float SPACING_MULTIPLIER = 1f;

    private static final String E_MISSING_TEXT = "E_MISSING_TEXT";
    private static final String E_MISSING_WIDTH = "E_MISSING_WIDTH";
    private static final String E_INVALID_SIZES = "E_INVALID_SIZES";
    private static final String E_INVALID_TYPE = "E_INVALID_TYPE";
    private static final String E_MISSING_PARAMETER = "E_MISSING_PARAMETER";
    private static final String E_UNKNOWN_ERROR = "E_UNKNOWN_ERROR";

    // It's important to pass the ANTI_ALIAS_FLAG flag to the constructor rather than setting it
    // later by calling setFlags. This is because the latter approach triggers a bug on Android 4.4.2.
    // The bug is that unicode emoticons aren't measured properly which causes text to be clipped.
    private static final TextPaint sTextPaintInstance = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);

    private final ReactApplicationContext mReactContext;

    RNTextSizeModule(ReactApplicationContext reactContext) {
        super(reactContext);
        mReactContext = reactContext;
    }

    @Override
    public String getName() {
        return TAG;
    }

    /**
     * Based on ReactTextShadowNode.java
     */
    @SuppressWarnings("unused")
    @ReactMethod
    public void measure(@Nullable final ReadableMap specs, final Promise promise) {
        final RNTextSizeConf conf = getConf(specs, promise, true);
        if (conf == null) {
            return;
        }

        final String _text = conf.getString("text");
        if (_text == null) {
            promise.reject(E_MISSING_TEXT, "Missing required text.");
            return;
        }

        final float density = getCurrentDensity();
        final float width = conf.getWidth(density);
        final boolean includeFontPadding = conf.includeFontPadding;

        final WritableMap result = Arguments.createMap();
        if (_text.isEmpty()) {
            result.putInt("width", 0);
            result.putDouble("height", minimalHeight(density, includeFontPadding));
            result.putInt("lastLineWidth", 0);
            result.putInt("lineCount", 0);
            promise.resolve(result);
            return;
        }

        final SpannableString text = (SpannableString) RNTextSizeSpannedText
                .spannedFromSpecsAndText(mReactContext, conf, new SpannableString(_text));

        final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
        Layout layout = null;
        try {
            final BoringLayout.Metrics boring = BoringLayout.isBoring(text, textPaint);
            int hintWidth = (int) width;

            if (boring == null) {
                // Not boring, ie. the text is multiline or contains unicode characters.
                final float desiredWidth = Layout.getDesiredWidth(text, textPaint);
                if (desiredWidth <= width) {
                    hintWidth = (int) Math.ceil(desiredWidth);
                }
            } else if (boring.width <= width) {
                // Single-line and width unknown or bigger than the width of the text.
                layout = BoringLayout.make(
                        text,
                        textPaint,
                        boring.width,
                        Layout.Alignment.ALIGN_NORMAL,
                        SPACING_MULTIPLIER,
                        SPACING_ADDITION,
                        boring,
                        includeFontPadding);
            }

            if (layout == null) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    layout = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth)
                            .setAlignment(Layout.Alignment.ALIGN_NORMAL)
                            .setBreakStrategy(conf.getTextBreakStrategy())
                            .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL)
                            .setIncludePad(includeFontPadding)
                            .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER)
                            .build();
                } else {
                    layout = new StaticLayout(
                            text,
                            textPaint,
                            hintWidth,
                            Layout.Alignment.ALIGN_NORMAL,
                            SPACING_MULTIPLIER,
                            SPACING_ADDITION,
                            includeFontPadding
                    );
                }
            }

            final int lineCount = layout.getLineCount();
            float rectWidth;

            if (conf.getBooleanOrTrue("usePreciseWidth")) {
                float lastWidth = 0f;
                // Layout.getWidth() returns the configured max width, we must
                // go slow to get the used one (and with the text trimmed).
                rectWidth = 0f;
                for (int i = 0; i < lineCount; i++) {
                    lastWidth = layout.getLineMax(i);
                    if (lastWidth > rectWidth) {
                        rectWidth = lastWidth;
                    }
                }
                result.putDouble("lastLineWidth", lastWidth / density);
            } else {
                rectWidth = layout.getWidth();
            }

            result.putDouble("width", Math.min(rectWidth / density, width));
            result.putDouble("height", layout.getHeight() / density);
            result.putInt("lineCount", lineCount);

            Integer lineInfoForLine = conf.getIntOrNull("lineInfoForLine");
            if (lineInfoForLine != null && lineInfoForLine >= 0) {
                final int line = Math.min(lineInfoForLine, lineCount);
                final WritableMap info = Arguments.createMap();
                info.putInt("line", line);
                info.putInt("start", layout.getLineStart(line));
                info.putInt("end", layout.getLineVisibleEnd(line));
                info.putDouble("bottom", layout.getLineBottom(line) / density);
                info.putDouble("width", layout.getLineMax(line) / density);
                result.putMap("lineInfo", info);
            }

            promise.resolve(result);
        } catch (Exception e) {
            promise.reject(E_UNKNOWN_ERROR, e);
        }
    }

    // https://stackoverflow.com/questions/3654321/measuring-text-height-to-be-drawn-on-canvas-android
    @SuppressWarnings("unused")
    @ReactMethod
    public void flatHeights(@Nullable final ReadableMap specs, final Promise promise) {
        final RNTextSizeConf conf = getConf(specs, promise, true);
        if (conf == null) {
            return;
        }

        final ReadableArray texts = conf.getArray("text");
        if (texts == null) {
            promise.reject(E_MISSING_TEXT, "Missing required text, must be an array.");
            return;
        }

        final float density = getCurrentDensity();
        final float width = conf.getWidth(density);
        final boolean includeFontPadding = conf.includeFontPadding;
        final int textBreakStrategy = conf.getTextBreakStrategy();

        final WritableArray result = Arguments.createArray();

        final SpannableStringBuilder sb = new SpannableStringBuilder(" ");
        RNTextSizeSpannedText.spannedFromSpecsAndText(mReactContext, conf, sb);

        final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
        Layout layout;
        try {

            for (int ix = 0; ix < texts.size(); ix++) {

                // If this element is `null` or another type, return zero
                if (texts.getType(ix) != ReadableType.String) {
                    result.pushInt(0);
                    continue;
                }

                final String text = texts.getString(ix);

                // If empty, return the minimum height of <Text> components
                if (text.isEmpty()) {
                    result.pushDouble(minimalHeight(density, includeFontPadding));
                    continue;
                }

                // Reset the SB text, the attrs will expand to its full length
                sb.replace(0, sb.length(), text);

                if (Build.VERSION.SDK_INT >= 23) {
                    layout = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, (int) width)
                            .setAlignment(Layout.Alignment.ALIGN_NORMAL)
                            .setBreakStrategy(textBreakStrategy)
                            .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL)
                            .setIncludePad(includeFontPadding)
                            .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER)
                            .build();
                } else {
                    layout = new StaticLayout(
                            sb,
                            textPaint,
                            (int) width,
                            Layout.Alignment.ALIGN_NORMAL,
                            SPACING_MULTIPLIER,
                            SPACING_ADDITION,
                            includeFontPadding
                    );
                }

                result.pushDouble(layout.getHeight() / density);
            }

            promise.resolve(result);
        } catch (Exception e) {
            promise.reject(E_UNKNOWN_ERROR, e);
        }
    }

    // https://stackoverflow.com/questions/3654321/measuring-text-height-to-be-drawn-on-canvas-android
    @SuppressWarnings("unused")
    @ReactMethod
    public void measureTextsLines(@Nullable final ReadableMap specs, final Promise promise) {
        final RNTextSizeConf conf = getConf(specs, promise, true);
        if (conf == null) {
            return;
        }

        final ReadableArray texts = conf.getArray("texts");
        if (texts == null) {
            promise.reject(E_MISSING_TEXT, "Missing required texts, must be an array.");
            return;
        }

        final ReadableArray widths = conf.getArray("widths");
        if (widths == null) {
            promise.reject(E_MISSING_WIDTH, "Missing required widths, must be an array.");
            return;
        }

        if (texts.size() != widths.size()) {
            promise.reject(E_INVALID_SIZES, "Sizes of texts and widths arrays are not equal.");
            return;
        }



        final float density = getCurrentDensity();
        final boolean includeFontPadding = conf.includeFontPadding;
        final int textBreakStrategy = conf.getTextBreakStrategy();

        final WritableArray result = Arguments.createArray();

        final SpannableStringBuilder sb = new SpannableStringBuilder(" ");
        RNTextSizeSpannedText.spannedFromSpecsAndText(mReactContext, conf, sb);

        final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
        Layout layout;
        try {

            for (int ix = 0; ix < texts.size(); ix++) {

                // If this element is not a string return error
                final ReadableType textType = texts.getType(ix);
                if (textType != ReadableType.String) {
                    promise.reject(E_INVALID_TYPE, "Value in texts array is not a string.");
                    return;
                }

                final String text = texts.getString(ix);

                // If empty, return the minimum height of <Text> components
                if (text.isEmpty()) {
                    result.pushDouble(minimalHeight(density, includeFontPadding));
                    continue;
                }

                // Reset the SB text, the attrs will expand to its full length
                sb.replace(0, sb.length(), text);

                int dipWidth;

                final ReadableType widthType = widths.getType(ix);
                if (widthType == ReadableType.Number) {
                    dipWidth = (int) Math.round(widths.getDouble(ix) * density);
                } else {
                    promise.reject(E_INVALID_TYPE, "Value in widths array is not a number.");
                    return;
                }

                if (Build.VERSION.SDK_INT >= 23) {
                    layout = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, dipWidth )
                            .setAlignment(Layout.Alignment.ALIGN_NORMAL)
                            .setBreakStrategy(textBreakStrategy)
                            .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL)
                            .setIncludePad(includeFontPadding)
                            .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER)
                            .build();
                } else {
                    layout = new StaticLayout(
                            sb,
                            textPaint,
                            dipWidth,
                            Layout.Alignment.ALIGN_NORMAL,
                            SPACING_MULTIPLIER,
                            SPACING_ADDITION,
                            includeFontPadding
                    );
                }

                result.pushInt(layout.getLineCount());
            }

            promise.resolve(result);
        } catch (Exception e) {
            promise.reject(E_UNKNOWN_ERROR, e);
        }
    }

    /**
     * See https://material.io/design/typography/#type-scale
     */
    @SuppressWarnings("unused")
    @ReactMethod
    public void specsForTextStyles(final Promise promise) {
        WritableMap result = Arguments.createMap();

        result.putMap("h1", makeFontSpecs("-light", 96, -1.5));
        result.putMap("h2", makeFontSpecs("-light", 60, -0.5));
        result.putMap("h3", makeFontSpecs(null, 48, 0));
        result.putMap("h4", makeFontSpecs(null, 34, 0.25));
        result.putMap("h5", makeFontSpecs(null, 24, 0));
        result.putMap("h6", makeFontSpecs("-medium", 20, 0.15));
        result.putMap("subtitle1", makeFontSpecs(null, 16, 0.15));
        result.putMap("subtitle2", makeFontSpecs("-medium", 14, 0.1));
        result.putMap("body1", makeFontSpecs(null, 16, 0.5));
        result.putMap("body2", makeFontSpecs(null, 14, 0.25));
        result.putMap("button", makeFontSpecs("-medium", 14, 0.75, true));
        result.putMap("caption", makeFontSpecs(null, 12, 0.4));
        result.putMap("overline", makeFontSpecs(null, 10, 1.5, true));

        promise.resolve(result);
    }

    /**
     * https://stackoverflow.com/questions/27631736
     * /meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font
     */
    @SuppressWarnings("unused")
    @ReactMethod
    public void fontFromSpecs(@Nullable final ReadableMap specs, final Promise promise) {
        final RNTextSizeConf conf = getConf(specs, promise);
        if (conf == null) {
            return;
        }
        final Typeface typeface = RNTextSizeConf.getFont(mReactContext, conf.fontFamily, conf.fontStyle);
        final TextPaint textPaint = sTextPaintInstance;
        final int fontSize = (int) Math.ceil(conf.scale(conf.fontSize));

        textPaint.reset();
        textPaint.setTypeface(typeface);
        textPaint.setTextSize(fontSize);

        promise.resolve(fontInfoFromTypeface(textPaint, typeface, conf));
    }

    @SuppressWarnings("unused")
    @ReactMethod
    public void fontFamilyNames(final Promise promise) {
        final boolean lollipop = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
        final WritableArray names = Arguments.createArray();

        names.pushString("sans-serif");
        names.pushString("sans-serif-condensed");
        if (lollipop) {
            names.pushString("sans-serif-thin");
            names.pushString("sans-serif-light");
            names.pushString("sans-serif-medium");
            names.pushString("sans-serif-black");
            names.pushString("sans-serif-smallcaps");
            names.pushString("sans-serif-condensed-light");
        } else {
            // SDK 16
            names.pushString("sans-serif-light");
        }
        names.pushString("serif");
        names.pushString("monospace");
        if (lollipop) {
            names.pushString("serif-monospace");
            names.pushString("casual");
            names.pushString("cursive");
        }

        getFontsInAssets(names);
        promise.resolve(names);
    }

    /**
     * Android does not have font name info.
     */
    @SuppressWarnings("unused")
    @ReactMethod
    public void fontNamesForFamilyName(final String ignored, final Promise promise) {
        promise.resolve(null);
    }

    // ============================================================================
    //
    //      Non-exposed instance & static methods
    //
    // ============================================================================

    @Nullable
    private RNTextSizeConf getConf(final ReadableMap specs, final Promise promise, boolean forText) {
        if (specs == null) {
            promise.reject(E_MISSING_PARAMETER, "Missing parameter object.");
            return null;
        }
        return new RNTextSizeConf(specs, forText);
    }

    @Nullable
    private RNTextSizeConf getConf(final ReadableMap specs, final Promise promise) {
        return getConf(specs, promise, false);
    }

    /**
     * RN consistently sets the height at 14dp divided by the density
     * plus 1 if includeFontPadding when text is empty, so we do the same.
     */
    private double minimalHeight(final float density, final boolean includeFontPadding) {
        final double height = 14.0 / density;
        return includeFontPadding ? height + 1.0 : height;
    }

    /**
     * This is for 'fontFromFontStyle', makes the minimal info required.
     * @param suffix The font variant
     * @param fontSize Font size in SP
     * @param letterSpacing Sugest this to user
     * @return map with specs
     */
    private WritableMap makeFontSpecs(String suffix, int fontSize, double letterSpacing, boolean upcase) {
        final WritableMap map = Arguments.createMap();
        final String roboto = "sans-serif";

        // In Android, the fontFamily determines the weight
        map.putString("fontFamily", suffix != null ? (roboto + suffix) : roboto);
        map.putInt("fontSize", fontSize);

        if (RNTextSizeConf.supportLetterSpacing()) {
            map.putDouble("letterSpacing", letterSpacing);
        }

        if (upcase && RNTextSizeConf.supportUpperCaseTransform()) {
            map.putString("textTransform", "uppercase");
        }

        return map;
    }

    private WritableMap makeFontSpecs(String suffix, int fontSize, double letterSpacing) {
        return makeFontSpecs(suffix, fontSize, letterSpacing, false);
    }

    @Nonnull
    private WritableMap fontInfoFromTypeface(
            @Nonnull final TextPaint textPaint,
            @Nonnull final Typeface typeface,
            @Nonnull final RNTextSizeConf conf
    ) {
        // Info is always in unscaled values
        final float density = getCurrentDensity();
        final Paint.FontMetrics metrics = new Paint.FontMetrics();
        final float lineHeight = textPaint.getFontMetrics(metrics);

        final WritableMap info = Arguments.createMap();
        info.putString("fontFamily", conf.getString("fontFamily"));
        info.putString("fontWeight", typeface.isBold() ? "bold" : "normal");
        info.putString("fontStyle", typeface.isItalic() ? "italic" : "normal");
        info.putDouble("fontSize", textPaint.getTextSize() / density);
        info.putDouble("leading", metrics.leading / density);
        info.putDouble("ascender", metrics.ascent / density);
        info.putDouble("descender", metrics.descent / density);
        info.putDouble("top", metrics.top / density);
        info.putDouble("bottom", metrics.bottom / density);
        info.putDouble("lineHeight", lineHeight / density);
        info.putInt("_hash", typeface.hashCode());
        return info;
    }

    /**
     * Retuns the current density.
     */
    @SuppressWarnings("deprecation")
    private float getCurrentDensity() {
        return DisplayMetricsHolder.getWindowDisplayMetrics().density;
    }

    private static final String[] FILE_EXTENSIONS = {".ttf", ".otf"};
    private static final String FONTS_ASSET_PATH = "fonts";

    private String[] fontsInAssets = null;

    /**
     * Set the font names in assets/fonts into the target array.
     * @param destArr Target
     */
    private void getFontsInAssets(@Nonnull WritableArray destArr) {
        String[] srcArr = fontsInAssets;

        if (srcArr == null) {
            final AssetManager assetManager = mReactContext.getAssets();
            ArrayList<String> tmpArr = new ArrayList<>();

            if (assetManager != null) {
                try {
                    String[] list = assetManager.list(FONTS_ASSET_PATH);

                    if (list != null) {
                        for (String spec : list) {
                            addFamilyToArray(tmpArr, spec);
                        }
                    }
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
            }

            Collections.sort(tmpArr, String.CASE_INSENSITIVE_ORDER);
            fontsInAssets = srcArr = tmpArr.toArray(new String[0]);
        }

        for (String name : srcArr) {
            destArr.pushString(name);
        }
    }

    private void addFamilyToArray(
            @Nonnull final List<String> outArr,
            @Nonnull final String spec
    ) {
        for (String ext : FILE_EXTENSIONS) {
            if (spec.endsWith(ext)) {
                final String name = spec.substring(0, spec.length() - ext.length());

                if (!outArr.contains(name)) {
                    outArr.add(name);
                }
                break;
            }
        }
    }
}
