/* ====================================================================
   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.
==================================================================== */

package org.apache.poi.ss.formula.functions;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.poi.ss.formula.FormulaParseException;
import org.apache.poi.ss.formula.FormulaParser;
import org.apache.poi.ss.formula.FormulaParsingWorkbook;
import org.apache.poi.ss.formula.OperationEvaluationContext;
import org.apache.poi.ss.formula.eval.BlankEval;
import org.apache.poi.ss.formula.eval.ErrorEval;
import org.apache.poi.ss.formula.eval.EvaluationException;
import org.apache.poi.ss.formula.eval.MissingArgEval;
import org.apache.poi.ss.formula.eval.OperandResolver;
import org.apache.poi.ss.formula.eval.ValueEval;
import org.apache.poi.ss.formula.ptg.Area3DPxg;
import org.apache.poi.ss.usermodel.Table;

/**
 * Implementation for Excel function INDIRECT<p>
 *
 * INDIRECT() returns the cell or area reference denoted by the text argument.<p>
 *
 * <b>Syntax</b>:<br>
 * <b>INDIRECT</b>(<b>ref_text</b>,isA1Style)<p>
 *
 * <b>ref_text</b> a string representation of the desired reference as it would
 * normally be written in a cell formula.<br>
 * <b>isA1Style</b> (default TRUE) specifies whether the ref_text should be
 * interpreted as A1-style or R1C1-style.
 */
public final class Indirect implements FreeRefFunction {

    private static final Logger LOGGER = LogManager.getLogger(Indirect.class);
    public static final FreeRefFunction instance = new Indirect();

    private Indirect() {
        // enforce singleton
    }

    @Override
    public ValueEval evaluate(ValueEval[] args, OperationEvaluationContext ec) {
        if (args.length < 1) {
            return ErrorEval.VALUE_INVALID;
        }

        boolean isA1style;
        String text;
        try {
            ValueEval ve = OperandResolver.getSingleValue(args[0], ec.getRowIndex(), ec
                    .getColumnIndex());
            text = OperandResolver.coerceValueToString(ve);
            switch (args.length) {
                case 1:
                    isA1style = true;
                    break;
                case 2:
                    isA1style = evaluateBooleanArg(args[1], ec);
                    break;
                default:
                    return ErrorEval.VALUE_INVALID;
            }
        } catch (EvaluationException e) {
            return e.getErrorEval();
        }

        return evaluateIndirect(ec, text, isA1style);
    }

    private static boolean evaluateBooleanArg(ValueEval arg, OperationEvaluationContext ec)
            throws EvaluationException {
        ValueEval ve = OperandResolver.getSingleValue(arg, ec.getRowIndex(), ec.getColumnIndex());

        if (ve == BlankEval.instance || ve == MissingArgEval.instance) {
            return false;
        }
        // numeric quantities follow standard boolean conversion rules
        // for strings, only "TRUE" and "FALSE" (case insensitive) are valid
        return OperandResolver.coerceValueToBoolean(ve, false);
    }

    private static ValueEval evaluateIndirect(final OperationEvaluationContext ec, String text,
                                              boolean isA1style) {

        // Search backwards for '!' because sheet names can contain '!'
        int plingPos = text.lastIndexOf('!');

        String workbookName;
        String sheetName;
        String refText; // whitespace around this gets trimmed OK
        if (plingPos < 0) {
            workbookName = null;
            sheetName = null;
            refText = text;
        } else {
            String[] parts = parseWorkbookAndSheetName(text.subSequence(0, plingPos));
            if (parts == null) {
                return ErrorEval.REF_INVALID;
            }
            workbookName = parts[0];
            sheetName = parts[1];
            refText = text.substring(plingPos + 1);
        }

        if (isA1style && Table.isStructuredReference.matcher(refText).matches()) {
            // The argument is structured reference
            Area3DPxg areaPtg;
            try {
                areaPtg = FormulaParser.parseStructuredReference(refText, (FormulaParsingWorkbook) ec.getWorkbook(), ec.getRowIndex());
            } catch (FormulaParseException e) {
                return ErrorEval.REF_INVALID;
            }
            return ec.getArea3DEval(areaPtg);
        } else {
            // The argument is regular reference
            String refStrPart1;
            String refStrPart2;
            int colonPos = refText.indexOf(':');
            if (colonPos < 0) {
                refStrPart1 = refText.trim();
                refStrPart2 = null;
            } else {
                refStrPart1 = refText.substring(0, colonPos).trim();
                refStrPart2 = refText.substring(colonPos + 1).trim();
            }
            try {
                return ec.getDynamicReference(workbookName, sheetName, refStrPart1, refStrPart2, isA1style);
            } catch (Exception e) {
                LOGGER.atWarn().log("Indirect function: failed to parse reference {}", text, e);
                return ErrorEval.REF_INVALID;
            }
        }
    }

    /**
     * @return array of length 2: {workbookName, sheetName,}.  Second element will always be
     * present.  First element may be null if sheetName is unqualified.
     * Returns {@code null} if text cannot be parsed.
     */
    private static String[] parseWorkbookAndSheetName(CharSequence text) {
        int lastIx = text.length() - 1;
        if (lastIx < 0) {
            return null;
        }
        if (canTrim(text)) {
            return null;
        }
        char firstChar = text.charAt(0);
        if (Character.isWhitespace(firstChar)) {
            return null;
        }
        if (firstChar == '\'') {
            // workbookName or sheetName needs quoting
            // quotes go around both
            if (text.charAt(lastIx) != '\'') {
                return null;
            }
            firstChar = text.charAt(1);
            if (Character.isWhitespace(firstChar)) {
                return null;
            }
            String wbName;
            int sheetStartPos;
            if (firstChar == '[') {
                int rbPos = text.toString().lastIndexOf(']');
                if (rbPos < 0) {
                    return null;
                }
                wbName = unescapeString(text.subSequence(2, rbPos));
                if (wbName == null || canTrim(wbName)) {
                    return null;
                }
                sheetStartPos = rbPos + 1;
            } else {
                wbName = null;
                sheetStartPos = 1;
            }

            // else - just sheet name
            String sheetName = unescapeString(text.subSequence(sheetStartPos, lastIx));
            if (sheetName == null) { // note - when quoted, sheetName can
                // start/end with whitespace
                return null;
            }
            return new String[] { wbName, sheetName, };
        }

        if (firstChar == '[') {
            int rbPos = text.toString().lastIndexOf(']');
            if (rbPos < 0) {
                return null;
            }
            CharSequence wbName = text.subSequence(1, rbPos);
            if (canTrim(wbName)) {
                return null;
            }
            CharSequence sheetName = text.subSequence(rbPos + 1, text.length());
            if (canTrim(sheetName)) {
                return null;
            }
            return new String[] { wbName.toString(), sheetName.toString(), };
        }
        // else - just sheet name
        return new String[] { null, text.toString(), };
    }

    /**
     * @return {@code null} if there is a syntax error in any escape sequence
     * (the typical syntax error is a single quote character not followed by another).
     */
    private static String unescapeString(CharSequence text) {
        int len = text.length();
        StringBuilder sb = new StringBuilder(len);
        int i = 0;
        while (i < len) {
            char ch = text.charAt(i);
            if (ch == '\'') {
                // every quote must be followed by another
                i++;
                if (i >= len) {
                    return null;
                }
                ch = text.charAt(i);
                if (ch != '\'') {
                    return null;
                }
            }
            sb.append(ch);
            i++;
        }
        return sb.toString();
    }

    private static boolean canTrim(CharSequence text) {
        int lastIx = text.length() - 1;
        if (lastIx < 0) {
            return false;
        }
        if (Character.isWhitespace(text.charAt(0))) {
            return true;
        }
        return Character.isWhitespace(text.charAt(lastIx));
    }
}
