/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.
 */

/* $Id: FOText.java 1761026 2016-09-16 12:50:43Z ssteiner $ */

package org.apache.fop.fo;

import java.awt.Color;
import java.nio.CharBuffer;
import java.text.CharacterIterator;
import java.text.StringCharacterIterator;
import java.util.NoSuchElementException;
import java.util.Stack;

import org.xml.sax.Locator;

import org.apache.fop.accessibility.StructureTreeElement;
import org.apache.fop.apps.FOPException;
import org.apache.fop.complexscripts.bidi.DelimitedTextRange;
import org.apache.fop.datatypes.Length;
import org.apache.fop.fo.flow.Block;
import org.apache.fop.fo.properties.CommonFont;
import org.apache.fop.fo.properties.CommonHyphenation;
import org.apache.fop.fo.properties.CommonTextDecoration;
import org.apache.fop.fo.properties.KeepProperty;
import org.apache.fop.fo.properties.Property;
import org.apache.fop.fo.properties.SpaceProperty;
import org.apache.fop.fonts.TextFragment;
import org.apache.fop.util.CharUtilities;

/**
 * A text node (PCDATA) in the formatting object tree.
 */
public class FOText extends FONode implements CharSequence, TextFragment {

    /** the <code>CharBuffer</code> containing the text */
    private CharBuffer charBuffer;

    // cached iterator
    private CharacterIterator charIterator;

    // The value of FO traits (refined properties) that apply to #PCDATA
    // (aka implicit sequence of fo:character)
    private CommonFont commonFont;
    private CommonHyphenation commonHyphenation;
    private Color color;
    private KeepProperty keepTogether;
    private Property letterSpacing;
    private SpaceProperty lineHeight;
    private int whiteSpaceTreatment;
    private int whiteSpaceCollapse;
    private int textTransform;
    private Property wordSpacing;
    private int wrapOption;
    private Length baselineShift;
    private String country;
    private String language;
    private String script;
    // End of trait values

    /**
     * Points to the previous FOText object created within the current
     * block. If this is "null", this is the first such object.
     */
    private FOText prevFOTextThisBlock;

    /**
     * Points to the next FOText object created within the current
     * block. If this is "null", this is the last such object.
     */
    private FOText nextFOTextThisBlock;

    /**
     * Points to the ancestor Block object. This is used to keep track of
     * which FOText nodes are descendants of the same block.
     */
    private Block ancestorBlock;

    /** Holds the text decoration values. May be null */
    private CommonTextDecoration textDecoration;

    private StructureTreeElement structureTreeElement;

    /* bidi levels */
    private int[] bidiLevels;

    private static final int IS_WORD_CHAR_FALSE = 0;
    private static final int IS_WORD_CHAR_TRUE = 1;
    private static final int IS_WORD_CHAR_MAYBE = 2;

    /**
     * Creates a new FO text node.
     *
     * @param parent FONode that is the parent of this object
     */
    public FOText(FONode parent) {
        super(parent);
    }

    /** {@inheritDoc} */
    protected void characters(char[] data, int start, int length,
            PropertyList list, Locator locator) throws FOPException {
        if (charBuffer == null) {
            // buffer not yet initialized, do so now
            int newLength = (length < 16) ? 16 : length;
            charBuffer = CharBuffer.allocate(newLength);
        } else {
            // allocate a larger buffer, and transfer contents
            int requires = charBuffer.position() + length;
            int capacity = charBuffer.capacity();
            if (requires > capacity) {
                int newCapacity = capacity * 2;
                if (requires > newCapacity) {
                    newCapacity = requires;
                }
                CharBuffer newBuffer = CharBuffer.allocate(newCapacity);
                charBuffer.rewind();
                newBuffer.put(charBuffer);
                charBuffer = newBuffer;
            }
        }
        // extend limit to capacity
        charBuffer.limit(charBuffer.capacity());
        // append characters
        charBuffer.put(data, start, length);
        // shrink limit to position
        charBuffer.limit(charBuffer.position());
    }

    /**
     * Return the array of characters for this instance.
     *
     * @return  a char sequence containing the text
     */
    public CharSequence getCharSequence() {
        if (this.charBuffer == null) {
            return null;
        }
        this.charBuffer.rewind();
        return this.charBuffer.asReadOnlyBuffer().subSequence(0, this.charBuffer.limit());
    }

    /** {@inheritDoc} */
    public FONode clone(FONode parent, boolean removeChildren)
            throws FOPException {
        FOText ft = (FOText) super.clone(parent, removeChildren);
        if (removeChildren) {
            // not really removing, just make sure the char buffer
            // pointed to is really a different one
            if (charBuffer != null) {
                ft.charBuffer = CharBuffer.allocate(charBuffer.limit());
                charBuffer.rewind();
                ft.charBuffer.put(charBuffer);
                ft.charBuffer.rewind();
            }
        }
        ft.prevFOTextThisBlock = null;
        ft.nextFOTextThisBlock = null;
        ft.ancestorBlock = null;
        return ft;
    }

    /** {@inheritDoc} */
    public void bind(PropertyList pList) throws FOPException {
        this.commonFont = pList.getFontProps();
        this.commonHyphenation = pList.getHyphenationProps();
        this.color = pList.get(Constants.PR_COLOR).getColor(getUserAgent());
        this.keepTogether = pList.get(Constants.PR_KEEP_TOGETHER).getKeep();
        this.lineHeight = pList.get(Constants.PR_LINE_HEIGHT).getSpace();
        this.letterSpacing = pList.get(Constants.PR_LETTER_SPACING);
        this.whiteSpaceCollapse = pList.get(Constants.PR_WHITE_SPACE_COLLAPSE).getEnum();
        this.whiteSpaceTreatment = pList.get(Constants.PR_WHITE_SPACE_TREATMENT).getEnum();
        this.textTransform = pList.get(Constants.PR_TEXT_TRANSFORM).getEnum();
        this.wordSpacing = pList.get(Constants.PR_WORD_SPACING);
        this.wrapOption = pList.get(Constants.PR_WRAP_OPTION).getEnum();
        this.textDecoration = pList.getTextDecorationProps();
        this.baselineShift = pList.get(Constants.PR_BASELINE_SHIFT).getLength();
        this.country = pList.get(Constants.PR_COUNTRY).getString();
        this.language = pList.get(Constants.PR_LANGUAGE).getString();
        this.script = pList.get(Constants.PR_SCRIPT).getString();
    }

    /** {@inheritDoc} */
    public void endOfNode() throws FOPException {
        if (charBuffer != null) {
            charBuffer.rewind();
        }
        super.endOfNode();
        getFOEventHandler().characters(this);
    }

    /** {@inheritDoc} */
    public void finalizeNode() {
        textTransform();
    }

    /**
     * Check if this text node will create an area.
     * This means either there is non-whitespace or it is
     * preserved whitespace.
     * Maybe this just needs to check length > 0, since char iterators
     * handle whitespace.
     *
     * @return true if this will create an area in the output
     */
    public boolean willCreateArea() {
        if (whiteSpaceCollapse == Constants.EN_FALSE
                && charBuffer.limit() > 0) {
            return true;
        }

        char ch;
        charBuffer.rewind();
        while (charBuffer.hasRemaining()) {
            ch = charBuffer.get();
            if (!((ch == CharUtilities.SPACE)
                    || (ch == CharUtilities.LINEFEED_CHAR)
                    || (ch == CharUtilities.CARRIAGE_RETURN)
                    || (ch == CharUtilities.TAB))) {
                // not whitespace
                charBuffer.rewind();
                return true;
            }
        }
        return false;
    }

    /**
     * @return a new TextCharIterator
     */
    public CharIterator charIterator() {
        return new TextCharIterator();
    }

     /**
     * This method is run as part of the ancestor Block's flushText(), to
     * create xref pointers to the previous FOText objects within the same Block
     * @param  ancestorBlock the ancestor fo:block
     */
    protected void createBlockPointers(Block ancestorBlock) {
        this.ancestorBlock = ancestorBlock;
        // if the last FOText is a sibling, point to it, and have it point here
        if (ancestorBlock.lastFOTextProcessed != null) {
            if (ancestorBlock.lastFOTextProcessed.ancestorBlock
                    == this.ancestorBlock) {
                prevFOTextThisBlock = ancestorBlock.lastFOTextProcessed;
                prevFOTextThisBlock.nextFOTextThisBlock = this;
            } else {
                prevFOTextThisBlock = null;
            }
        }
    }

    /**
     * This method is run as part of endOfNode(), to handle the
     * text-transform property for accumulated FOText
     */
    private void textTransform() {
        if (getBuilderContext().inMarker()
                || textTransform == Constants.EN_NONE) {
            return;
        }

        charBuffer.rewind();
        CharBuffer tmp = charBuffer.slice();
        char c;
        int lim = charBuffer.limit();
        int pos = -1;
        while (++pos < lim) {
            c = charBuffer.get();
            switch (textTransform) {
                case Constants.EN_UPPERCASE:
                    tmp.put(Character.toUpperCase(c));
                    break;
                case Constants.EN_LOWERCASE:
                    tmp.put(Character.toLowerCase(c));
                    break;
                case Constants.EN_CAPITALIZE:
                    if (isStartOfWord(pos)) {
                        /*
                         Use toTitleCase here. Apparently, some languages use
                         a different character to represent a letter when using
                         initial caps than when all of the letters in the word
                         are capitalized. We will try to let Java handle this.
                        */
                        tmp.put(Character.toTitleCase(c));
                    } else {
                        tmp.put(c);
                    }
                    break;
                default:
                     //should never happen as the property subsystem catches that case
                    assert false;
                    //nop
            }
        }
    }

    /**
     * Determines whether a particular location in an FOText object's text is
     * the start of a new "word". The use of "word" here is specifically for
     * the text-transform property, but may be useful for other things as
     * well, such as word-spacing. The definition of "word" is somewhat ambiguous
     * and appears to be definable by the user agent.
     *
     * @param i index into charBuffer
     *
     * @return True if the character at this location is the start of a new
     * word.
     */
    private boolean isStartOfWord(int i) {
        char prevChar = getRelativeCharInBlock(i, -1);
        /* All we are really concerned about here is of what type prevChar
         * is. If inputChar is not part of a word, then the Java
         * conversions will (we hope) simply return inputChar.
         */
        switch (isWordChar(prevChar)) {
            case IS_WORD_CHAR_TRUE:
                return false;
            case IS_WORD_CHAR_FALSE:
                return true;
            /* "MAYBE" implies that additional context is needed. An example is a
             * single-quote, either straight or closing, which might be interpreted
             * as a possessive or a contraction, or might be a closing quote.
             */
            case IS_WORD_CHAR_MAYBE:
                char prevPrevChar = getRelativeCharInBlock(i, -2);
                switch (isWordChar(prevPrevChar)) {
                case IS_WORD_CHAR_TRUE:
                    return false;
                case IS_WORD_CHAR_FALSE:
                    return true;
                case IS_WORD_CHAR_MAYBE:
                    return true;
                default:
                    return false;
            }
            default:
                return false;
        }
    }

    /**
     * Finds a character within the current Block that is relative in location
     * to a character in the current FOText. Treats all FOText objects within a
     * block as one unit, allowing text in adjoining FOText objects to be
     * returned if the parameters are outside of the current object.
     *
     * @param i index into the CharBuffer
     * @param offset signed integer with relative position within the
     *   block of the character to return. To return the character immediately
     *   preceding i, pass -1. To return the character immediately after i,
     *   pass 1.
     * @return the character in the offset position within the block; \u0000 if
     * the offset points to an area outside of the block.
     */
    private char getRelativeCharInBlock(int i, int offset) {

        int charIndex = i + offset;
        // The easy case is where the desired character is in the same FOText
        if (charIndex >= 0 && charIndex < this.length()) {
            return this.charAt(i + offset);
        }

        // For now, we can't look at following FOText nodes
        if (offset > 0) {
             return CharUtilities.NULL_CHAR;
        }

        // Remaining case has the text in some previous FOText node
        boolean foundChar = false;
        char charToReturn = CharUtilities.NULL_CHAR;
        FOText nodeToTest = this;
        int remainingOffset = offset + i;
        while (!foundChar) {
            if (nodeToTest.prevFOTextThisBlock == null) {
                break;
            }
            nodeToTest = nodeToTest.prevFOTextThisBlock;
            int diff = nodeToTest.length() + remainingOffset - 1;
            if (diff >= 0) {
                charToReturn = nodeToTest.charAt(diff);
                foundChar = true;
            } else {
                remainingOffset += diff;
            }
        }
        return charToReturn;
    }

    /**
     * @return The previous FOText node in this Block; null, if this is the
     * first FOText in this Block.
     */
    //public FOText getPrevFOTextThisBlock () {
    //    return prevFOTextThisBlock;
    //}

    /**
     * @return The next FOText node in this Block; null if this is the last
     * FOText in this Block; null if subsequent FOText nodes have not yet been
     * processed.
     */
    //public FOText getNextFOTextThisBlock () {
    //    return nextFOTextThisBlock;
    //}

    /**
     * @return The nearest ancestor block object which contains this FOText.
     */
    //public Block getAncestorBlock () {
    //    return ancestorBlock;
    //}

    /**
     * Determines whether the input char should be considered part of a
     * "word". This is used primarily to determine whether the character
     * immediately following starts a new word, but may have other uses.
     * We have not found a definition of "word" in the standard (1.0), so the
     * logic used here is based on the programmer's best guess.
     *
     * @param inputChar the character to be tested.
     * @return int IS_WORD_CHAR_TRUE, IS_WORD_CHAR_FALSE, or IS_WORD_CHAR_MAYBE,
     * depending on whether the character should be considered part of a word
     * or not.
     */
    private static int isWordChar(char inputChar) {
        switch (Character.getType(inputChar)) {
        case Character.COMBINING_SPACING_MARK:
            return IS_WORD_CHAR_TRUE;
        case Character.CONNECTOR_PUNCTUATION:
            return IS_WORD_CHAR_TRUE;
        case Character.CONTROL:
            return IS_WORD_CHAR_FALSE;
        case Character.CURRENCY_SYMBOL:
            return IS_WORD_CHAR_TRUE;
        case Character.DASH_PUNCTUATION:
            if (inputChar == '-') {
                return IS_WORD_CHAR_TRUE; //hyphen
            }
            return IS_WORD_CHAR_FALSE;
        case Character.DECIMAL_DIGIT_NUMBER:
            return IS_WORD_CHAR_TRUE;
        case Character.ENCLOSING_MARK:
            return IS_WORD_CHAR_FALSE;
        case Character.END_PUNCTUATION:
            if (inputChar == '\u2019') {
                return IS_WORD_CHAR_MAYBE; //apostrophe, right single quote
            }
            return IS_WORD_CHAR_FALSE;
        case Character.FORMAT:
            return IS_WORD_CHAR_FALSE;
        case Character.LETTER_NUMBER:
            return IS_WORD_CHAR_TRUE;
        case Character.LINE_SEPARATOR:
            return IS_WORD_CHAR_FALSE;
        case Character.LOWERCASE_LETTER:
            return IS_WORD_CHAR_TRUE;
        case Character.MATH_SYMBOL:
            return IS_WORD_CHAR_FALSE;
        case Character.MODIFIER_LETTER:
            return IS_WORD_CHAR_TRUE;
        case Character.MODIFIER_SYMBOL:
            return IS_WORD_CHAR_TRUE;
        case Character.NON_SPACING_MARK:
            return IS_WORD_CHAR_TRUE;
        case Character.OTHER_LETTER:
            return IS_WORD_CHAR_TRUE;
        case Character.OTHER_NUMBER:
            return IS_WORD_CHAR_TRUE;
        case Character.OTHER_PUNCTUATION:
            if (inputChar == '\'') {
                return IS_WORD_CHAR_MAYBE; //ASCII apostrophe
            }
            return IS_WORD_CHAR_FALSE;
        case Character.OTHER_SYMBOL:
            return IS_WORD_CHAR_TRUE;
        case Character.PARAGRAPH_SEPARATOR:
            return IS_WORD_CHAR_FALSE;
        case Character.PRIVATE_USE:
            return IS_WORD_CHAR_FALSE;
        case Character.SPACE_SEPARATOR:
            return IS_WORD_CHAR_FALSE;
        case Character.START_PUNCTUATION:
            return IS_WORD_CHAR_FALSE;
        case Character.SURROGATE:
            return IS_WORD_CHAR_FALSE;
        case Character.TITLECASE_LETTER:
            return IS_WORD_CHAR_TRUE;
        case Character.UNASSIGNED:
            return IS_WORD_CHAR_FALSE;
        case Character.UPPERCASE_LETTER:
            return IS_WORD_CHAR_TRUE;
        default:
            return IS_WORD_CHAR_FALSE;
        }
    }

    private class TextCharIterator extends CharIterator {

        private int currentPosition;

        private boolean canRemove;
        private boolean canReplace;

        public TextCharIterator() {
        }

        /** {@inheritDoc} */
        public boolean hasNext() {
           return (this.currentPosition < charBuffer.limit());
        }

        /** {@inheritDoc} */
        public char nextChar() {

            if (this.currentPosition < charBuffer.limit()) {
                this.canRemove = true;
                this.canReplace = true;
                return charBuffer.get(currentPosition++);
            } else {
                throw new NoSuchElementException();
            }

        }

        /** {@inheritDoc} */
        public void remove() {

            if (this.canRemove) {
                charBuffer.position(currentPosition);
                // Slice the buffer at the current position
                CharBuffer tmp = charBuffer.slice();
                // Reset position to before current character
                charBuffer.position(--currentPosition);
                if (tmp.hasRemaining()) {
                    // Transfer any remaining characters
                    charBuffer.mark();
                    charBuffer.put(tmp);
                    charBuffer.reset();
                }
                // Decrease limit
                charBuffer.limit(charBuffer.limit() - 1);
                // Make sure following calls fail, unless nextChar() was called
                this.canRemove = false;
            } else {
                throw new IllegalStateException();
            }

        }

        /** {@inheritDoc} */
        public void replaceChar(char c) {

            if (this.canReplace) {
                charBuffer.put(currentPosition - 1, c);
            } else {
                throw new IllegalStateException();
            }

        }

    }

    /**
     * @return the Common Font Properties.
     */
    public CommonFont getCommonFont() {
        return commonFont;
    }

    /**
     * @return the Common Hyphenation Properties.
     */
    public CommonHyphenation getCommonHyphenation() {
        return commonHyphenation;
    }

    /**
     * @return the "color" trait.
     */
    public Color getColor() {
        return color;
    }

    /**
     * @return the "keep-together" trait.
     */
    public KeepProperty getKeepTogether() {
        return keepTogether;
    }

    /**
     * @return the "letter-spacing" trait.
     */
    public Property getLetterSpacing() {
        return letterSpacing;
    }

    /**
     * @return the "line-height" trait.
     */
    public SpaceProperty getLineHeight() {
        return lineHeight;
    }

    /**
     * @return the "white-space-treatment" trait
     */
    public int getWhitespaceTreatment() {
        return whiteSpaceTreatment;
    }

    /**
     * @return the "word-spacing" trait.
     */
    public Property getWordSpacing() {
        return wordSpacing;
    }

    /**
     * @return the "wrap-option" trait.
     */
    public int getWrapOption() {
        return wrapOption;
    }

    /** @return the "text-decoration" trait. */
    public CommonTextDecoration getTextDecoration() {
        return textDecoration;
    }

    /** @return the baseline-shift trait */
    public Length getBaseLineShift() {
        return baselineShift;
    }

    /** @return the country trait */
    public String getCountry() {
        return country;
    }

    @Override
    public synchronized CharacterIterator getIterator() {
        if (charIterator != null) {
            charIterator = new StringCharacterIterator(toString());
        }
        return charIterator;
    }

    @Override
    public int getBeginIndex() {
        return 0;
    }

    @Override
    public int getEndIndex() {
        return length();
    }

    @Override
    public String getLanguage() {
        return language;
    }

    @Override
    public String getScript() {
        return script;
    }

    @Override
    public int getBidiLevel() {
        return length() > 0 ? bidiLevelAt(0) : -1;
    }

    /** {@inheritDoc} */
    public String toString() {
        if (charBuffer == null) {
            return "";
        } else {
            CharBuffer cb = charBuffer.duplicate();
            cb.rewind();
            return cb.toString();
        }
    }

    /** {@inheritDoc} */
    public String getLocalName() {
        return "#PCDATA";
    }

    /** {@inheritDoc} */
    public String getNormalNamespacePrefix() {
        return null;
    }

    /** {@inheritDoc} */
    protected String gatherContextInfo() {
        if (this.locator != null) {
            return super.gatherContextInfo();
        } else {
            return this.toString();
        }
    }

    /** {@inheritDoc} */
    public char charAt(int position) {
        return charBuffer.get(position);
    }

    /** {@inheritDoc} */
    public CharSequence subSequence(int start, int end) {
        return charBuffer.subSequence(start, end);
    }

    /** {@inheritDoc} */
    public int length() {
        return charBuffer.limit();
    }

    /**
     * Resets the backing <code>java.nio.CharBuffer</code>
     */
    public void resetBuffer() {
        if (charBuffer != null) {
            charBuffer.rewind();
        }
    }

    @Override
    public boolean isDelimitedTextRangeBoundary(int boundary) {
        return false;
    }

    @Override
    public void setStructureTreeElement(StructureTreeElement structureTreeElement) {
        this.structureTreeElement = structureTreeElement;
    }

    @Override
    public StructureTreeElement getStructureTreeElement() {
        return structureTreeElement;
    }

    /**
     * Set bidirectional level over interval [start,end).
     * @param level the resolved level
     * @param start the starting index of interval
     * @param end the ending index of interval
     */
    public void setBidiLevel(int level, int start, int end) {
        if (start < end) {
            if (bidiLevels == null) {
                bidiLevels = new int [ length() ];
            }
            for (int i = start, n = end; i < n; i++) {
                bidiLevels [ i ] = level;
            }
            if (parent != null) {
                ((FObj) parent).setBidiLevel(level);
            }
        } else {
            assert start < end;
        }
    }

    /**
     * Obtain bidirectional level of each character
     * represented by this FOText.
     * @return a (possibly empty) array of bidi levels or null
     * in case no bidi levels have been assigned
     */
    public int[] getBidiLevels() {
        return bidiLevels;
    }

    /**
     * Obtain bidirectional level of each character over
     * interval [start,end).
     * @param start the starting index of interval
     * @param end the ending index of interval
     * @return a (possibly empty) array of bidi levels or null
     * in case no bidi levels have been assigned
     */
    public int[] getBidiLevels(int start, int end) {
        if (this.bidiLevels != null) {
            assert start <= end;
            int n = end - start;
            int[] bidiLevels = new int [ n ];
            System.arraycopy(this.bidiLevels, start + 0, bidiLevels, 0, n);
            return bidiLevels;
        } else {
            return null;
        }
    }

    /**
     * Obtain bidirectional level of character at
     * specified position, which must be a non-negative integer
     * less than the length of this FO.
     * @param position an offset position into FO's characters
     * @return a resolved bidi level or -1 if default
     * @throws IndexOutOfBoundsException if position is not non-negative integer
     * or is greater than or equal to length
     */
    public int bidiLevelAt(int position) throws IndexOutOfBoundsException {
        if ((position < 0) || (position >= length())) {
            throw new IndexOutOfBoundsException();
        } else if (bidiLevels != null) {
            return bidiLevels [ position ];
        } else {
            return -1;
        }
    }

    @Override
    protected Stack<DelimitedTextRange> collectDelimitedTextRanges(Stack<DelimitedTextRange> ranges,
        DelimitedTextRange currentRange) {
        if (currentRange != null) {
            currentRange.append(charIterator(), this);
        }
        return ranges;
    }

    private static class MapRange {
        private int start;
        private int end;
        MapRange(int start, int end) {
            this.start = start;
            this.end = end;
        }
        public int hashCode() {
            return (start * 31) + end;
        }
        public boolean equals(Object o) {
            if (o instanceof MapRange) {
                MapRange r = (MapRange) o;
                return (r.start == start) && (r.end == end);
            } else {
                return false;
            }
        }
    }

}
