/*
 * 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.openjpa.lib.meta;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.security.AccessController;
import java.security.PrivilegedActionException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.openjpa.lib.util.ClassUtil;
import org.apache.openjpa.lib.util.Files;
import org.apache.openjpa.lib.util.J2DoPrivHelper;
import org.apache.openjpa.lib.util.Localizer;
import org.apache.xbean.asm9.ClassReader;
import org.apache.xbean.asm9.ClassVisitor;
import org.apache.xbean.asm9.Opcodes;


/**
 * Parser used to resolve arguments into java classes.
 * Interprets command-line args as either class names, .class files or
 * resources, .java files or resources, or metadata files or resources
 * conforming to the common format defined by {@link CFMetaDataParser}.
 * Transforms the information in these args into {@link Class} instances.
 * Note that when parsing .java files, only the main class in the file
 * is detected. Other classes defined in the file, such as inner classes,
 * are not added to the returned classes list.
 *
 * @author Abe White
 */
public class ClassArgParser {

    private static final int TOKEN_EOF = -1;
    private static final int TOKEN_NONE = 0;
    private static final int TOKEN_PACKAGE = 1;
    private static final int TOKEN_CLASS = 2;
    private static final int TOKEN_PACKAGE_NOATTR = 3;
    private static final int TOKEN_CLASS_NOATTR = 4;

    private static final Localizer _loc = Localizer.forPackage
        (ClassArgParser.class);

    private ClassLoader _loader = null;
    private char[] _packageAttr = "name".toCharArray();
    private char[] _classAttr = "name".toCharArray();
    private char[][] _beginElements = { { 'p' }, { 'c' } };
    private char[][] _endElements = { "ackage".toCharArray(),
        "lass".toCharArray() };

    /**
     * The class loader with which to load parsed classes.
     */
    public ClassLoader getClassLoader() {
        return _loader;
    }

    /**
     * The class loader with which to load parsed classes.
     */
    public void setClassLoader(ClassLoader loader) {
        _loader = loader;
    }

    /**
     * Set the the relevant metadata file structure so that metadata files
     * containing class names can be parsed. Null attribute names indicate
     * that the text content of the element contains the data.
     */
    public void setMetaDataStructure(String packageElementName,
        String packageAttributeName, String[] classElementNames,
        String classAttributeName) {
        // calculate how many chars deep we have to go to identify each element
        // name as unique.  this is extremely inefficient for large N, but
        // should never be called for more than a few elements
        char[] buf = new char[classElementNames.length + 1];
        int charIdx = 0;
        for (; true; charIdx++) {
            for (int i = 0; i < buf.length; i++) {
                if (i == 0) {
                    if (charIdx == packageElementName.length())
                        throw new UnsupportedOperationException(_loc.get
                            ("cant-diff-elems").getMessage());
                    buf[i] = packageElementName.charAt(charIdx);
                } else {
                    if (charIdx == classElementNames[i - 1].length())
                        throw new UnsupportedOperationException(_loc.get
                            ("cant-diff-elems").getMessage());
                    buf[i] = classElementNames[i - 1].charAt(charIdx);
                }
            }
            if (charsUnique(buf))
                break;
        }

        _packageAttr = (packageAttributeName == null) ? null
            : packageAttributeName.toCharArray();
        _classAttr = (classAttributeName == null) ? null
            : classAttributeName.toCharArray();
        _beginElements = new char[classElementNames.length + 1][];
        _endElements = new char[classElementNames.length + 1][];
        _beginElements[0] = packageElementName.substring(0, charIdx + 1).
            toCharArray();
        _endElements[0] = packageElementName.substring(charIdx + 1).
            toCharArray();
        for (int i = 0; i < classElementNames.length; i++) {
            _beginElements[i + 1] = classElementNames[i].
                substring(0, charIdx + 1).toCharArray();
            _endElements[i + 1] = classElementNames[i].
                substring(charIdx + 1).toCharArray();
        }
    }

    /**
     * Return true if all characters in given buffer are unique.
     */
    private static boolean charsUnique(char[] buf) {
        for (int i = buf.length - 1; i >= 0; i--)
            for (int j = 0; j < i; j++)
                if (buf[j] == buf[i])
                    return false;
        return true;
    }

    /**
     * Return the {@link Class} representation of the class(es) named in the
     * given arg.
     *
     * @param arg a class name, .java file, .class file, or metadata
     * file naming the type(s) to act on
     */
    public Class<?>[] parseTypes(String arg) {
        String[] names = parseTypeNames(arg);
        Class<?>[] objs = new Class[names.length];
        for (int i = 0; i < names.length; i++)
            objs[i] = ClassUtil.toClass(names[i], _loader);
        return objs;
    }

    /**
     * Return the {@link Class} representation of the class(es) named in the
     * given metadatas.
     */
    public Class<?>[] parseTypes(MetaDataIterator itr) {
        String[] names = parseTypeNames(itr);
        Class<?>[] objs = new Class[names.length];
        for (int i = 0; i < names.length; i++)
            objs[i] = ClassUtil.toClass(names[i], _loader);
        return objs;
    }

    /**
     * Return a mapping of each metadata resource to an array of its
     * contained classes.
     */
    public Map<Object, Class<?>[]> mapTypes(MetaDataIterator itr) {
        Map<Object, String[]> map = mapTypeNames(itr);
        Map<Object, Class<?>[]> rval = new HashMap<>();
        Map.Entry<Object, String[]> entry;
        String[] names;
        Class<?>[] objs;
        for (Map.Entry<Object, String[]> objectEntry : map.entrySet()) {
            entry = objectEntry;
            names = entry.getValue();
            objs = new Class[names.length];
            for (int j = 0; j < names.length; j++) {
                objs[j] = ClassUtil.toClass(names[j], _loader);
            }
            rval.put(entry.getKey(), objs);
        }
        return rval;
    }

    /**
     * Return the names of the class(es) from the given arg.
     *
     * @param arg a class name, .java file, .class file, or metadata
     * file naming the type(s) to act on
     * @throws IllegalArgumentException with appropriate message on error
     */
    public String[] parseTypeNames(String arg) {
        if (arg == null)
            return new String[0];

        try {
            File file = Files.getFile(arg, _loader);
            if (arg.endsWith(".class"))
                return new String[]{ getFromClassFile(file) };
            if (arg.endsWith(".java"))
                return new String[]{ getFromJavaFile(file) };
            if (AccessController.doPrivileged(
                    J2DoPrivHelper.existsAction(file))) {
                Collection<String> col = getFromMetaDataFile(file);
                return col.toArray(new String[col.size()]);
            }
        } catch (Exception e) {
            throw new RuntimeException(
                _loc.get("class-arg", arg).getMessage(), e);
        }

        // must be a class name
        return new String[]{ arg };
    }

    /**
     * Return the names of the class(es) from the given metadatas.
     */
    public String[] parseTypeNames(MetaDataIterator itr) {
        if (itr == null)
            return new String[0];

        List<String> names = new ArrayList<>();
        Object source = null;
        try {
            while (itr.hasNext()) {
                source = itr.next();
                appendTypeNames(source, itr.getInputStream(), names);
            }
        } catch (Exception e) {
            throw new RuntimeException(
                _loc.get("class-arg", source).getMessage(), e);
        }
        return names.toArray(new String[names.size()]);
    }

    /**
     * Parse the names in the given metadata iterator stream, closing the
     * stream on completion.
     */
    private void appendTypeNames(Object source, InputStream in, List<String> names) throws IOException {
        try {
            if (source.toString().endsWith(".class")) {
                names.add(getNameFromClass(in));
            }
            names.addAll(getFromMetaData(new InputStreamReader(in)));
        } finally {
            try {
                in.close();
            } catch (IOException ioe) {
            }
        }
    }

    /**
     * Return a mapping of each metadata resource to an array of its contained
     * class names.
     */
    public Map<Object, String[]> mapTypeNames(MetaDataIterator itr) {
        if (itr == null)
            return Collections.emptyMap();

        Map<Object, String []> map = new HashMap<>();
        Object source = null;
        List<String> names = new ArrayList<>();
        try {
            while (itr.hasNext()) {
                source = itr.next();
                appendTypeNames(source, itr.getInputStream(), names);
                if (!names.isEmpty()) {
                    map.put(source, names.toArray(new String[names.size()]));
                }
                names.clear();
            }
        } catch (Exception e) {
            throw new RuntimeException(
                _loc.get("class-arg", source).getMessage(), e);
        }
        return map;
    }

    /**
     * Returns the class named in the given .class file.
     */
    private String getFromClassFile(File file) throws IOException {
        FileInputStream fin = null;
        try {
            fin = AccessController.doPrivileged(J2DoPrivHelper.newFileInputStreamAction(file));
            return getNameFromClass(fin);
        } catch (PrivilegedActionException pae) {
            throw (FileNotFoundException) pae.getException();
        } finally {
            if (fin != null) {
                try {
                    fin.close();
                }
                catch (IOException ioe) {
                }
            }
        }
    }

    /**
     * Returns the class name in the given .class bytecode.
     */
    private String getNameFromClass(InputStream in) throws IOException {

        ClassReader cr = new ClassReader(in);
        final ClassNameScanner classNameScanner = new ClassNameScanner(Opcodes.ASM9);
        cr.accept(classNameScanner, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);

        return classNameScanner.className;
    }

    /**
     * Returns the class named in the given .java file.
     */
    private String getFromJavaFile(File file) throws IOException {
        BufferedReader in = null;
        try {
            // find the line with the package declaration
            in = new BufferedReader(new FileReader(file));
            String line;
            StringBuilder pack = null;
            while ((line = in.readLine()) != null) {
                line = line.trim();
                if (line.startsWith("package ")) {
                    line = line.substring(8).trim();

                    // strip off anything beyond the package declaration
                    pack = new StringBuilder();
                    for (int i = 0; i < line.length(); i++) {
                        if (Character.isJavaIdentifierPart(line.charAt(i))
                            || line.charAt(i) == '.')
                            pack.append(line.charAt(i));
                        else
                            break;
                    }
                    break;
                }
            }

            // strip '.java'
            String clsName = file.getName();
            clsName = clsName.substring(0, clsName.length() - 5);

            // prefix with package
            if (pack != null && pack.length() > 0)
                clsName = pack + "." + clsName;

            return clsName;
        } finally {
            if (in != null)
                try { in.close(); } catch (IOException ioe) {}
        }
    }

    /**
     * Returns the classes named in the given common format metadata file.
     */
    private Collection<String> getFromMetaDataFile(File file)
        throws IOException {
        FileReader in = null;
        try {
            in = new FileReader(file);
            return getFromMetaData(in);
        } finally {
            if (in != null)
                try {
                    in.close();
                } catch (IOException ioe) {
                }
        }
    }

    /**
     * Returns the classes named in the given common format metadata stream.
     */
    private Collection<String> getFromMetaData(Reader xml) throws IOException {
        Collection<String> names = new ArrayList<>();
        BufferedReader in = new BufferedReader(xml);

        boolean comment = false;
        int token = TOKEN_NONE;
        String pkg = "";
        String name;
        read:
        for (int ch = 0, last = 0, last2 = 0;
            ch == '<' || (ch = in.read()) != -1; last2 = last, last = ch) {
            // handle comments
            if (comment && last2 == '-' && last == '-' && ch == '>') {
                comment = false;
                continue;
            }
            if (comment) {
                if (ch == '<') {
                    ch = in.read();
                    if (ch == -1)
                        break read;
                }
                continue;
            }
            if (last2 == '<' && last == '!' && ch == '-') {
                comment = true;
                continue;
            }

            // if not an element start, skip it
            if (ch != '<')
                continue;
            token = TOKEN_NONE; // reset token
            last = ch; // update needed for comment detection
            ch = readThroughWhitespace(in);
            if (ch == '/' || ch == '!' || ch == '?')
                continue;

            // read element name; look for packages and classes
            token = readElementToken(ch, in);
            switch (token) {
                case TOKEN_EOF:
                    break read;
                case TOKEN_PACKAGE:
                    pkg = readAttribute(in, _packageAttr);
                    if (pkg == null)
                        break read;
                    break;
                case TOKEN_PACKAGE_NOATTR:
                    pkg = readElementText(in);
                    if (pkg == null)
                        break read;
                    ch = '<'; // reading element text reads to next '<'
                    break;
                case TOKEN_CLASS:
                    name = readAttribute(in, _classAttr);
                    if (name == null)
                        break read;
                    if (pkg.length() > 0 && name.indexOf('.') == -1)
                        names.add(pkg + "." + name);
                    else
                        names.add(name);
                    break;
                case TOKEN_CLASS_NOATTR:
                    name = readElementText(in);
                    if (name == null)
                        break read;
                    ch = '<'; // reading element text reads to next '<'
                    if (pkg.length() > 0 && name.indexOf('.') == -1)
                        names.add(pkg + "." + name);
                    else
                        names.add(name);
                    break;
            }
        }
        return names;
    }

    /**
     * Read the name of the current XML element and return the matching token.
     */
    private int readElementToken(int ch, Reader in) throws IOException {
        // look through the beginning element names to find what element this
        // might be(if any)
        int matchIdx = -1;
        int matched = 0;
        int dq = 0;
        for (int beginIdx = 0; beginIdx < _beginElements[0].length; beginIdx++)
        {
            if (beginIdx != 0)
                ch = in.read();
            if (ch == -1)
                return TOKEN_EOF;

            matched = 0;
            for (int i = 0; i < _beginElements.length; i++) {
                if ((dq & (2 << i)) != 0)
                    continue;

                if (ch == _beginElements[i][beginIdx]) {
                    matchIdx = i;
                    matched++;
                } else
                    dq |= 2 << i;
            }

            if (matched == 0)
                break;
        }
        if (matched != 1)
            return TOKEN_NONE;

        // make sure the rest of the element name matches
        char[] match = _endElements[matchIdx];
        for (char c : match) {
            ch = in.read();
            if (ch == -1)
                return TOKEN_EOF;
            if (ch != c)
                return TOKEN_NONE;
        }

        // read the next char to make sure we finished the element name
        ch = in.read();
        if (ch == -1)
            return TOKEN_EOF;
        if (ch == '>') {
            if (matchIdx == 0 && _packageAttr == null)
                return TOKEN_PACKAGE_NOATTR;
            if (matchIdx != 0 && _classAttr == null)
                return TOKEN_CLASS_NOATTR;
        } else if (Character.isWhitespace((char) ch)) {
            if (matchIdx == 0 && _packageAttr != null)
                return TOKEN_PACKAGE;
            if (matchIdx != 0 && _classAttr != null)
                return TOKEN_CLASS;
        }
        return TOKEN_NONE;
    }

    /**
     * Read the attribute with the given name in chars of the current XML
     * element.
     */
    private String readAttribute(Reader in, char[] name) throws IOException {
        int expected = 0;
        for (int ch, last = 0; true; last = ch) {
            ch = in.read();
            if (ch == -1)
                return null;
            if (ch == '>')
                return "";

            // if not expected char or still looking for 'n' and previous
            // char is not whitespace, keep looking
            if (ch != name[expected] || (expected == 0 && last != 0
                && !Character.isWhitespace((char) last))) {
                expected = 0;
                continue;
            }

            // found expected char; have we found the whole "name"?
            expected++;
            if (expected == name.length) {
                // make sure the next char is '='
                ch = readThroughWhitespace(in);
                if (ch == -1)
                    return null;
                if (ch != '=') {
                    expected = 0;
                    continue;
                }

                // toss out any subsequent whitespace and the next char, which
                // is the opening quote for the attr value, then read until the
                // closing quote
                readThroughWhitespace(in);
                return readAttributeValue(in);
            }
        }
    }

    /**
     * Read the current text value until the next element.
     */
    private String readElementText(Reader in) throws IOException {
        StringBuilder buf = null;
        int ch;
        while (true) {
            ch = in.read();
            if (ch == -1)
                return null;
            if (ch == '<')
                break;
            if (Character.isWhitespace((char) ch))
                continue;
            if (buf == null)
                buf = new StringBuilder();
            buf.append((char) ch);
        }
        return (buf == null) ? "" : buf.toString();
    }

    /**
     * Read until the next non-whitespace character.
     */
    private int readThroughWhitespace(Reader in) throws IOException {
        int ch;
        while (true) {
            ch = in.read();
            if (ch == -1 || !Character.isWhitespace((char) ch))
                return ch;
        }
    }

    /**
     * Return the current attribute value.
     */
    private String readAttributeValue(Reader in) throws IOException {
        StringBuilder buf = new StringBuilder();
        int ch;
        while (true) {
            ch = in.read();
            if (ch == -1)
                return null;
            if (ch == '\'' || ch == '"')
                return buf.toString();
            buf.append((char) ch);
        }
    }


    public class ClassNameScanner extends ClassVisitor {
        String className = null;

        public ClassNameScanner(int api) {
            super(api);
        }

        @Override
        public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
            this.className = name.replace("/", ".");
            super.visit(version, access, name, signature, superName, interfaces);
        }
    }
}
