// 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 org.apache.tapestry5.internal.services;

import java.util.Collection;
import java.util.Collections;
import java.util.Formatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import org.apache.tapestry5.SymbolConstants;
import org.apache.tapestry5.commons.services.InvalidationListener;
import org.apache.tapestry5.commons.util.AvailableValues;
import org.apache.tapestry5.commons.util.CollectionFactory;
import org.apache.tapestry5.commons.util.UnknownValueException;
import org.apache.tapestry5.func.F;
import org.apache.tapestry5.internal.InternalConstants;
import org.apache.tapestry5.ioc.annotations.Symbol;
import org.apache.tapestry5.ioc.internal.util.InternalUtils;
import org.apache.tapestry5.ioc.services.ClassNameLocator;
import org.apache.tapestry5.services.ComponentClassResolver;
import org.apache.tapestry5.services.LibraryMapping;
import org.apache.tapestry5.services.transform.ControlledPackageType;
import org.slf4j.Logger;

public class ComponentClassResolverImpl implements ComponentClassResolver, InvalidationListener
{
    private static final String CORE_LIBRARY_PREFIX = "core/";

    private static final Pattern SPLIT_PACKAGE_PATTERN = Pattern.compile("\\.");

    private static final Pattern SPLIT_FOLDER_PATTERN = Pattern.compile("/");

    private static final int LOGICAL_NAME_BUFFER_SIZE = 40;

    private final Logger logger;

    private final ClassNameLocator classNameLocator;

    private final String startPageName;

    // Map from library name to a list of root package names (usuallly just one).
    private final Map<String, List<String>> libraryNameToPackageNames = CollectionFactory.newCaseInsensitiveMap();

    private final Map<String, ControlledPackageType> packageNameToType = CollectionFactory.newMap();

    /**
     * Maps from a root package name to a component library name, including the empty string as the
     * library name of the application.
     */
    private final Map<String, String> packageNameToLibraryName = CollectionFactory.newMap();

    // Flag indicating that the maps have been cleared following an invalidation
    // and need to be rebuilt. The flag and the four maps below are not synchronized
    // because they are only modified inside a synchronized block. That should be strong enough ...
    // and changes made will become "visible" at the end of the synchronized block. Because of the
    // structure of Tapestry, there should not be any reader threads while the write thread
    // is operating.

    private volatile boolean needsRebuild = true;

    private final Collection<LibraryMapping> libraryMappings;

    private final Pattern endsWithPagePattern = Pattern.compile(".*/?\\w+page$", Pattern.CASE_INSENSITIVE);

    private boolean endsWithPage(String name)
    {
        // Don't treat a name that's just "page" as a suffix to strip off.

        return endsWithPagePattern.matcher(name).matches();
    }

    private class Data
    {

        /**
         * Logical page name to class name.
         */
        private final Map<String, String> pageToClassName = CollectionFactory.newCaseInsensitiveMap();

        /**
         * Component type to class name.
         */
        private final Map<String, String> componentToClassName = CollectionFactory.newCaseInsensitiveMap();

        /**
         * Mixing type to class name.
         */
        private final Map<String, String> mixinToClassName = CollectionFactory.newCaseInsensitiveMap();

        /**
         * Page class name to logical name (needed to build URLs). This one is case sensitive, since class names do always
         * have a particular case.
         */
        private final Map<String, String> pageClassNameToLogicalName = CollectionFactory.newMap();

        /**
         * Used to convert a logical page name to the canonical form of the page name; this ensures that uniform case for
         * page names is used.
         */
        private final Map<String, String> pageNameToCanonicalPageName = CollectionFactory.newCaseInsensitiveMap();


        /**
         * These are used to check for name overlaps: a single name (generated by different paths) that maps to more than one class.
         */
        private Map<String, Set<String>> pageToClassNames = CollectionFactory.newCaseInsensitiveMap();

        private Map<String, Set<String>> componentToClassNames = CollectionFactory.newCaseInsensitiveMap();

        private Map<String, Set<String>> mixinToClassNames = CollectionFactory.newCaseInsensitiveMap();

        private boolean invalid = false;

        private void rebuild(String pathPrefix, String rootPackage)
        {
            fill(pathPrefix, rootPackage, InternalConstants.PAGES_SUBPACKAGE, pageToClassName, pageToClassNames);
            fill(pathPrefix, rootPackage, InternalConstants.COMPONENTS_SUBPACKAGE, componentToClassName, componentToClassNames);
            fill(pathPrefix, rootPackage, InternalConstants.MIXINS_SUBPACKAGE, mixinToClassName, mixinToClassNames);
        }

        private void fill(String pathPrefix, String rootPackage, String subPackage,
                          Map<String, String> logicalNameToClassName,
                          Map<String, Set<String>> nameToClassNames)
        {
            String searchPackage = rootPackage + "." + subPackage;
            boolean isPage = subPackage.equals(InternalConstants.PAGES_SUBPACKAGE);

            Collection<String> classNames = classNameLocator.locateClassNames(searchPackage);

            Set<String> aliases = CollectionFactory.newSet();

            int startPos = searchPackage.length() + 1;

            for (String className : classNames)
            {
                aliases.clear();

                String logicalName = toLogicalName(className, pathPrefix, startPos, true);
                String unstrippedName = toLogicalName(className, pathPrefix, startPos, false);

                aliases.add(logicalName);
                aliases.add(unstrippedName);

                if (isPage)
                {
                    if (endsWithPage(logicalName))
                    {
                        logicalName = logicalName.substring(0, logicalName.length() - 4);
                        aliases.add(logicalName);
                    }

                    int lastSlashx = logicalName.lastIndexOf("/");

                    String lastTerm = lastSlashx < 0 ? logicalName : logicalName.substring(lastSlashx + 1);

                    if (lastTerm.equalsIgnoreCase("index"))
                    {
                        String reducedName = lastSlashx < 0 ? "" : logicalName.substring(0, lastSlashx);

                        // Make the super-stripped name another alias to the class.
                        // TAP5-1444: Everything else but a start page has precedence


                        aliases.add(reducedName);
                    }

                    if (logicalName.equals(startPageName))
                    {
                        aliases.add("");
                    }

                    pageClassNameToLogicalName.put(className, logicalName);
                }

                for (String alias : aliases)
                {
                    logicalNameToClassName.put(alias, className);
                    addNameMapping(nameToClassNames, alias, className);

                    if (isPage)
                    {
                        pageNameToCanonicalPageName.put(alias, logicalName);
                    }
                }
            }
        }

        /**
         * Converts a fully qualified class name to a logical name
         *
         * @param className
         *         fully qualified class name
         * @param pathPrefix
         *         prefix to be placed on the logical name (to identify the library from in which the class
         *         lives)
         * @param startPos
         *         start position within the class name to extract the logical name (i.e., after the final '.' in
         *         "rootpackage.pages.").
         * @param stripTerms
         * @return a short logical name in folder format ('.' replaced with '/')
         */
        private String toLogicalName(String className, String pathPrefix, int startPos, boolean stripTerms)
        {
            List<String> terms = CollectionFactory.newList();

            addAll(terms, SPLIT_FOLDER_PATTERN, pathPrefix);

            addAll(terms, SPLIT_PACKAGE_PATTERN, className.substring(startPos));

            StringBuilder builder = new StringBuilder(LOGICAL_NAME_BUFFER_SIZE);
            String sep = "";

            String logicalName = terms.remove(terms.size() - 1);

            String unstripped = logicalName;

            for (String term : terms)
            {
                builder.append(sep);
                builder.append(term);

                sep = "/";

                if (stripTerms)
                {
                    logicalName = stripTerm(term, logicalName);
                }
            }

            if (logicalName.equals(""))
            {
                logicalName = unstripped;
            }

            builder.append(sep);
            builder.append(logicalName);

            return builder.toString();
        }

        private void addAll(List<String> terms, Pattern splitter, String input)
        {
            for (String term : splitter.split(input))
            {
                if (term.equals(""))
                    continue;

                terms.add(term);
            }
        }

        private String stripTerm(String term, String logicalName)
        {
            if (isCaselessPrefix(term, logicalName))
            {
                logicalName = logicalName.substring(term.length());
            }

            if (isCaselessSuffix(term, logicalName))
            {
                logicalName = logicalName.substring(0, logicalName.length() - term.length());
            }

            return logicalName;
        }

        private boolean isCaselessPrefix(String prefix, String string)
        {
            return string.regionMatches(true, 0, prefix, 0, prefix.length());
        }

        private boolean isCaselessSuffix(String suffix, String string)
        {
            return string.regionMatches(true, string.length() - suffix.length(), suffix, 0, suffix.length());
        }

        private void addNameMapping(Map<String, Set<String>> map, String name, String className)
        {
            Set<String> classNames = map.get(name);

            if (classNames == null)
            {
                classNames = CollectionFactory.newSet();
                map.put(name, classNames);
            }

            classNames.add(className);
        }

        private void validate()
        {
            validate("page name", pageToClassNames);
            validate("component type", componentToClassNames);
            validate("mixin type", mixinToClassNames);

            // No longer needed after validation.
            pageToClassNames = null;
            componentToClassNames = null;
            mixinToClassNames = null;

            if (invalid)
            {
                throw new IllegalStateException("You must correct these validation issues to proceed.");
            }
        }

        private void validate(String category, Map<String, Set<String>> map)
        {
            boolean header = false;

            for (String name : F.flow(map.keySet()).sort())
            {
                Set<String> classNames = map.get(name);

                if (classNames.size() == 1)
                {
                    continue;
                }

                if (!header)
                {
                    logger.error(String.format("Some %s(s) map to more than one Java class.", category));
                    header = true;
                    invalid = true;
                }

                logger.error(String.format("%s '%s' maps to %s",
                        InternalUtils.capitalize(category),
                        name,
                        InternalUtils.joinSorted(classNames)));
            }
        }
    }

    private volatile Data data = new Data();

    public ComponentClassResolverImpl(Logger logger,

                                      ClassNameLocator classNameLocator,

                                      @Symbol(SymbolConstants.START_PAGE_NAME)
                                      String startPageName,

                                      Collection<LibraryMapping> mappings)
    {
        this.logger = logger;
        this.classNameLocator = classNameLocator;

        this.startPageName = startPageName;
        this.libraryMappings = Collections.unmodifiableCollection(mappings);

        for (LibraryMapping mapping : mappings)
        {
            String libraryName = mapping.libraryName;

            List<String> packages = this.libraryNameToPackageNames.get(libraryName);

            if (packages == null)
            {
                packages = CollectionFactory.newList();
                this.libraryNameToPackageNames.put(libraryName, packages);
            }

            packages.add(mapping.rootPackage);

            // These packages, which will contain classes subject to class transformation,
            // must be registered with the component instantiator (which is responsible
            // for transformation).

            addSubpackagesToPackageMapping(mapping.rootPackage);

            packageNameToLibraryName.put(mapping.rootPackage, libraryName);
        }
    }

    private void addSubpackagesToPackageMapping(String rootPackage)
    {
        for (String subpackage : InternalConstants.SUBPACKAGES)
        {
            packageNameToType.put(rootPackage + "." + subpackage, ControlledPackageType.COMPONENT);
        }
    }

    public Map<String, ControlledPackageType> getControlledPackageMapping()
    {
        return Collections.unmodifiableMap(packageNameToType);
    }

    /**
     * When the class loader is invalidated, clear any cached page names or component types.
     */
    public synchronized void objectWasInvalidated()
    {
        needsRebuild = true;
    }

    /**
     * Returns the current data, or atomically rebuilds it. In rare race conditions, the data may be rebuilt more than once, overlapping.
     */
    private Data getData()
    {
        if (!needsRebuild)
        {
            return data;
        }

        Data newData = new Data();

        for (Map.Entry<String, List<String>> entry : libraryNameToPackageNames.entrySet())
        {
            List<String> packages = entry.getValue();

            String folder = entry.getKey() + "/";

            for (String packageName : packages)
            {
                newData.rebuild(folder, packageName);
            }
        }

        newData.validate();

        showChanges("pages", data.pageToClassName, newData.pageToClassName);
        showChanges("components", data.componentToClassName, newData.componentToClassName);
        showChanges("mixins", data.mixinToClassName, newData.mixinToClassName);

        needsRebuild = false;

        data = newData;

        return data;
    }

    private static int countUnique(Map<String, String> map)
    {
        return CollectionFactory.newSet(map.values()).size();
    }

    /**
     * Log (at INFO level) the changes between the two logical-name-to-class-name maps
     *
     * @param title
     *         the title of the things in the maps (e.g. "pages" or "components")
     * @param savedMap
     *         the old map
     * @param newMap
     *         the new map
     */
    private void showChanges(String title, Map<String, String> savedMap, Map<String, String> newMap)
    {
        if (savedMap.equals(newMap) || !logger.isInfoEnabled()) // nothing to log?
        {
            return;
        }

        Map<String, String> core = CollectionFactory.newMap();
        Map<String, String> nonCore = CollectionFactory.newMap();


        int maxLength = 0;

        // Pass # 1: Get all the stuff in the core library

        for (String name : newMap.keySet())
        {
            if (name.startsWith(CORE_LIBRARY_PREFIX))
            {
                // Strip off the "core/" prefix.

                String key = name.substring(CORE_LIBRARY_PREFIX.length());

                maxLength = Math.max(maxLength, key.length());

                core.put(key, newMap.get(name));
            } else
            {
                maxLength = Math.max(maxLength, name.length());

                nonCore.put(name, newMap.get(name));
            }
        }

        // Merge the non-core mappings into the core mappings. Where there are conflicts on name, it
        // means the application overrode a core page/component/mixin and that's ok ... the
        // merged core map will reflect the application's mapping.

        core.putAll(nonCore);

        StringBuilder builder = new StringBuilder(2000);
        Formatter f = new Formatter(builder);

        int oldCount = countUnique(savedMap);
        int newCount = countUnique(newMap);

        f.format("Available %s (%d", title, newCount);

        if (oldCount > 0 && oldCount != newCount)
        {
            f.format(", +%d", newCount - oldCount);
        }

        builder.append("):\n");

        String formatString = "%" + maxLength + "s: %s\n";

        List<String> sorted = InternalUtils.sortedKeys(core);

        for (String name : sorted)
        {
            String className = core.get(name);

            if (name.equals(""))
                name = "(blank)";

            f.format(formatString, name, className);
        }

        // log multi-line string with OS-specific line endings (TAP5-2294)
        logger.info(builder.toString().replaceAll("\\n", System.getProperty("line.separator")));
    }


    public String resolvePageNameToClassName(final String pageName)
    {
        Data data = getData();

        String result = locate(pageName, data.pageToClassName);

        if (result == null)
        {
            throw new UnknownValueException(String.format("Unable to resolve '%s' to a page class name.",
                    pageName), new AvailableValues("Page names", presentableNames(data.pageToClassName)));
        }

        return result;
    }

    public boolean isPageName(final String pageName)
    {
        return locate(pageName, getData().pageToClassName) != null;
    }

    public boolean isPage(final String pageClassName)
    {
        return locate(pageClassName, getData().pageClassNameToLogicalName) != null;
    }


    public List<String> getPageNames()
    {
        Data data = getData();

        List<String> result = CollectionFactory.newList(data.pageClassNameToLogicalName.values());

        Collections.sort(result);

        return result;
    }

    public List<String> getComponentNames()
    {
        Data data = getData();

        List<String> result = CollectionFactory.newList(data.componentToClassName.keySet());

        Collections.sort(result);

        return result;
    }

    public List<String> getMixinNames()
    {
        Data data = getData();

        List<String> result = CollectionFactory.newList(data.mixinToClassName.keySet());

        Collections.sort(result);

        return result;
    }

    public String resolveComponentTypeToClassName(final String componentType)
    {
        Data data = getData();

        String result = locate(componentType, data.componentToClassName);

        if (result == null)
        {
            throw new UnknownValueException(String.format("Unable to resolve '%s' to a component class name.",
                    componentType), new AvailableValues("Component types",
                    presentableNames(data.componentToClassName)));
        }

        return result;
    }

    Collection<String> presentableNames(Map<String, ?> map)
    {
        Set<String> result = CollectionFactory.newSet();

        for (String name : map.keySet())
        {

            if (name.startsWith(CORE_LIBRARY_PREFIX))
            {
                result.add(name.substring(CORE_LIBRARY_PREFIX.length()));
                continue;
            }

            result.add(name);
        }

        return result;
    }

    public String resolveMixinTypeToClassName(final String mixinType)
    {
        Data data = getData();

        String result = locate(mixinType, data.mixinToClassName);

        if (result == null)
        {
            throw new UnknownValueException(String.format("Unable to resolve '%s' to a mixin class name.",
                    mixinType), new AvailableValues("Mixin types", presentableNames(data.mixinToClassName)));
        }

        return result;
    }

    /**
     * Locates a class name within the provided map, given its logical name. If not found naturally, a search inside the
     * "core" library is included.
     *
     * @param logicalName
     *         name to search for
     * @param logicalNameToClassName
     *         mapping from logical name to class name
     * @return the located class name or null
     */
    private String locate(String logicalName, Map<String, String> logicalNameToClassName)
    {
        String result = logicalNameToClassName.get(logicalName);

        // If not found, see if it exists under the core package. In this way,
        // anything in core is "inherited" (but overridable) by the application.

        if (result != null)
        {
            return result;
        }

        return logicalNameToClassName.get(CORE_LIBRARY_PREFIX + logicalName);
    }

    public String resolvePageClassNameToPageName(final String pageClassName)
    {
        String result = getData().pageClassNameToLogicalName.get(pageClassName);

        if (result == null)
        {
            throw new IllegalArgumentException(String.format("Unable to resolve class name %s to a logical page name.", pageClassName));
        }

        return result;
    }

    public String canonicalizePageName(final String pageName)
    {
        Data data = getData();

        String result = locate(pageName, data.pageNameToCanonicalPageName);

        if (result == null)
        {
            throw new UnknownValueException(String.format("Unable to resolve '%s' to a known page name.",
                    pageName), new AvailableValues("Page names", presentableNames(data.pageNameToCanonicalPageName)));
        }

        return result;
    }

    public Map<String, String> getFolderToPackageMapping()
    {
        Map<String, String> result = CollectionFactory.newCaseInsensitiveMap();

        for (Map.Entry<String, List<String>> entry : libraryNameToPackageNames.entrySet())
        {
            String folder = entry.getKey();

            List<String> packageNames = entry.getValue();

            String packageName = findCommonPackageNameForFolder(folder, packageNames);

            result.put(folder, packageName);
        }

        return result;
    }

    static String findCommonPackageNameForFolder(String folder, List<String> packageNames)
    {
        String packageName = findCommonPackageName(packageNames);

        if (packageName == null)
            throw new RuntimeException(
                    String.format(
                            "Package names for library folder '%s' (%s) can not be reduced to a common base package (of at least one term).",
                            folder, InternalUtils.joinSorted(packageNames)));
        return packageName;
    }

    static String findCommonPackageName(List<String> packageNames)
    {
        // BTW, this is what reduce is for in Clojure ...

        String commonPackageName = packageNames.get(0);

        for (int i = 1; i < packageNames.size(); i++)
        {
            commonPackageName = findCommonPackageName(commonPackageName, packageNames.get(i));

            if (commonPackageName == null)
                break;
        }

        return commonPackageName;
    }

    static String findCommonPackageName(String commonPackageName, String packageName)
    {
        String[] commonExploded = explode(commonPackageName);
        String[] exploded = explode(packageName);

        int count = Math.min(commonExploded.length, exploded.length);

        int commonLength = 0;
        int commonTerms = 0;

        for (int i = 0; i < count; i++)
        {
            if (exploded[i].equals(commonExploded[i]))
            {
                // Keep track of the number of shared characters (including the dot seperators)

                commonLength += exploded[i].length() + (i == 0 ? 0 : 1);
                commonTerms++;
            } else
            {
                break;
            }
        }

        if (commonTerms < 1)
            return null;

        return commonPackageName.substring(0, commonLength);
    }

    private static final Pattern DOT = Pattern.compile("\\.");

    private static String[] explode(String packageName)
    {
        return DOT.split(packageName);
    }

    public List<String> getLibraryNames()
    {
        return F.flow(libraryNameToPackageNames.keySet()).remove(F.IS_BLANK).sort().toList();
    }

    public String getLibraryNameForClass(String className)
    {
        assert className != null;

        String current = className;

        while (true)
        {

            int dotx = current.lastIndexOf('.');

            if (dotx < 1)
            {
                throw new IllegalArgumentException(String.format("Class %s is not inside any package associated with any library.",
                        className));
            }

            current = current.substring(0, dotx);

            String libraryName = packageNameToLibraryName.get(current);

            if (libraryName != null)
            {
                return libraryName;
            }
        }
    }

    @Override
    public Collection<LibraryMapping> getLibraryMappings()
    {
        return libraryMappings;
    }

    @Override
    public String getLogicalName(String className) 
    {
        final Data thisData = getData();
        String result = thisData.pageClassNameToLogicalName.get(className);
        if (result == null)
        {
            result = getKeyByValue(thisData.componentToClassName, className);
        }
        if (result == null )
        {
            result = getKeyByValue(thisData.mixinToClassName, className);
        }

        return result;
    }
    
    @Override
    public String getClassName(String logicalName) 
    {
        final Data thisData = getData();
        String result = getKeyByValue(thisData.pageClassNameToLogicalName, logicalName);
        if (result == null)
        {
            result = thisData.componentToClassName.get(logicalName);
        }
        if (result == null )
        {
            result = thisData.mixinToClassName.get(logicalName);
        }
        return result;
    }

    
    private String getKeyByValue(Map<String, String> map, String value)
    {
        return map.entrySet().stream()
                .filter(e -> e.getValue().equals(value))
                .map(e -> e.getKey())
                .findAny()
                .orElse(null);
    }

}
