/*******************************************************************************
 * Copyright (c) 2001, 2023 IBM Corporation and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/

package org.eclipse.wst.xml.core.internal.validation;

import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.net.URL;
import java.net.URLConnection;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.preferences.InstanceScope;
import org.eclipse.wst.common.uriresolver.internal.provisional.URIResolver;
import org.eclipse.wst.common.uriresolver.internal.provisional.URIResolverPlugin;
import org.eclipse.wst.common.uriresolver.internal.util.URIHelper;
import org.eclipse.wst.xml.core.internal.Logger;
import org.eclipse.wst.xml.core.internal.XMLCorePlugin;
import org.eclipse.wst.xml.core.internal.preferences.XMLCorePreferenceNames;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXNotRecognizedException;
import org.xml.sax.SAXNotSupportedException;
import org.xml.sax.SAXParseException;
import org.xml.sax.XMLReader;
import org.xml.sax.ext.LexicalHandler;

import com.ibm.icu.util.StringTokenizer;

/**
 * A helper class for the XML validator.
 *
 * @author Craig Salter, IBM
 * @author Lawrence Mandel, IBM
 */
public class ValidatorHelper
{
  private static final int TWO_MINUTES = (int) Duration.ofMinutes(2).toMillis();
  public boolean isGrammarEncountered = false;
  public boolean isDTDEncountered = false;
  public boolean isNamespaceEncountered = false;
  public String schemaLocationString = ""; //$NON-NLS-1$
  public int numDTDElements = 0;
  public boolean isDocumentElementEncountered = false;

  private static class GrammarEncounteredResult {
    boolean result;
    private long timestamp;

    GrammarEncounteredResult(boolean value) {
      this.result = value;
      this.timestamp = System.currentTimeMillis();
    }
  }

  private static final Map<String, GrammarEncounteredResult> grammarCheckerCache = new HashMap<>(3);

  private static final boolean _trace = Boolean.valueOf(Platform.getDebugOption("org.eclipse.wst.xml.core/debug/validation")).booleanValue(); //$NON-NLS-1$
  /**
   * Constructor.
   */
  public ValidatorHelper()
  {
  }

  /**
   * Create an XML Reader.
   *
   * @return An XML Reader if one can be created or null.
   * @throws SAXNotSupportedException
   * @throws SAXNotRecognizedException
   */
  protected XMLReader createXMLReader(String uri) throws SAXNotRecognizedException, SAXNotSupportedException {
    XMLReader reader = null;

    ClassLoader originalClzLoader = Thread.currentThread().getContextClassLoader();
	Thread.currentThread().setContextClassLoader(getClass().getClassLoader());

	try{
		reader = new org.apache.xerces.parsers.SAXParser();
	}finally{
		Thread.currentThread().setContextClassLoader(originalClzLoader);
	}

    reader.setFeature("http://apache.org/xml/features/continue-after-fatal-error", false); //$NON-NLS-1$
    reader.setFeature("http://xml.org/sax/features/namespace-prefixes", true); //$NON-NLS-1$
    reader.setFeature("http://xml.org/sax/features/namespaces", false); //$NON-NLS-1$
    reader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); //$NON-NLS-1$

    // http://bugs.eclipse.org/508083
    String xmlCoreId = XMLCorePlugin.getDefault().getBundle().getSymbolicName();
    boolean resolveExternalEntities = InstanceScope.INSTANCE.getNode(xmlCoreId).getBoolean(XMLCorePreferenceNames.RESOLVE_EXTERNAL_ENTITIES, false);

    reader.setFeature("http://xml.org/sax/features/external-general-entities", resolveExternalEntities); //$NON-NLS-1$
    reader.setFeature("http://xml.org/sax/features/external-parameter-entities", resolveExternalEntities); //$NON-NLS-1$

    reader.setContentHandler(new MyContentHandler(uri));
    reader.setErrorHandler(new InternalErrorHandler());

    LexicalHandler lexicalHandler = new LexicalHandler()
    {
      public void startDTD (String name, String publicId, String systemId)
      {
        isGrammarEncountered = true;
        isDTDEncountered = true;
      }

      public void endDTD() throws SAXException
      {
      }

      public void startEntity(String name) throws SAXException
      {
      }

      public void endEntity(String name) throws SAXException
      {
      }

      public void startCDATA() throws SAXException
      {
      }

      public void endCDATA() throws SAXException
      {
      }

      public void comment (char ch[], int start, int length) throws SAXException
      {
      }
    };
    reader.setProperty("http://xml.org/sax/properties/lexical-handler", lexicalHandler); //$NON-NLS-1$

    return reader;
  }

  /**
   * An error handler to suppress error and warning information.
   */
  private class InternalErrorHandler implements org.xml.sax.ErrorHandler
  {
	public InternalErrorHandler()
	{
	  super();
	}

    public void error(SAXParseException exception) throws SAXException
    {
    }

    public void fatalError(SAXParseException exception) throws SAXException
    {
    }

    public void warning(SAXParseException exception) throws SAXException
    {
    }
  }


  /**
   * Figures out the information needed for validation.
   *
   * @param uri The uri of the file to validate.
   * @param uriResolver A helper to resolve locations.
   */
  public void computeValidationInformation(String uri, Reader characterStream, URIResolver uriResolver)
  {
	  try {
	      XMLReader reader = createXMLReader(uri);
	      InputSource inputSource = new InputSource(uri);
	      inputSource.setCharacterStream(characterStream);

	      ClassLoader originalClzLoader = Thread.currentThread().getContextClassLoader();
	      Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
	      try {
	    	  reader.parse(inputSource);
	      }
	      finally {
	    	  Thread.currentThread().setContextClassLoader(originalClzLoader);
	      }


	  }
	  catch (SAXException e) {
		  if (_trace)
			  Logger.logException(e);
	  }
	  catch (IOException e) {
		  if (_trace)
			  Logger.logException(e);
	  }
  }



  /**
   * Handle the content while parsing the file.
   */
  class MyContentHandler extends org.xml.sax.helpers.DefaultHandler
  {
    /* (non-Javadoc)
     * @see org.xml.sax.ErrorHandler#error(org.xml.sax.SAXParseException)
     */
    boolean isRootElement = true;
    String baseURI;

    MyContentHandler(String uri)
    {
      this.baseURI = uri;
    }

    public void error(SAXParseException e) throws SAXException
    {
    }
    /* (non-Javadoc)
     * @see org.xml.sax.ErrorHandler#fatalError(org.xml.sax.SAXParseException)
     */
    public void fatalError(SAXParseException e) throws SAXException
    {
    }
    /* (non-Javadoc)
     * @see org.xml.sax.ErrorHandler#warning(org.xml.sax.SAXParseException)
     */
    public void warning(SAXParseException e) throws SAXException
    {
    }
    public String getPrefix(String name)
    {
      String prefix = null;
      int index = name.indexOf(":"); //$NON-NLS-1$
      if (index != -1)
      {
        prefix = name.substring(0, index);
      }
      return prefix;
    }

    public String getUnprefixedName(String name)
    {
      int index = name.indexOf(":"); //$NON-NLS-1$
      if (index != -1)
      {
        name = name.substring(index + 1);
      }
      return name;
    }

    public String getPrefixedName(String prefix, String localName)
    {
      return prefix != null && prefix.length() > 0 ? prefix + ":" + localName : localName;      //$NON-NLS-1$
    }

    public void startElement(String namespaceURI, String localName, String rawName, Attributes atts)
    {
      //String explicitLocation = null;
      if (isRootElement)
      {
        isDocumentElementEncountered = true;
        isRootElement = false;
        int nAtts = atts.getLength();
        String schemaInstancePrefix = null;
        for (int i =0; i < nAtts; i++)
        {
          String attributeName = atts.getQName(i);
          if (attributeName.equals("xmlns") || attributeName.startsWith("xmlns:")) //$NON-NLS-1$ //$NON-NLS-2$
          {
            isNamespaceEncountered = true;
            String value = atts.getValue(i);
            if (value.startsWith("http://www.w3.org/") && value.endsWith("/XMLSchema-instance")) //$NON-NLS-1$ //$NON-NLS-2$
            {
              schemaInstancePrefix = attributeName.equals("xmlns") ? "" : getUnprefixedName(attributeName); //$NON-NLS-1$ //$NON-NLS-2$
            }
          }
        }

        String prefix = getPrefix(rawName);
        String rootElementNamespaceDeclarationName = (prefix != null && prefix.length() > 0) ? "xmlns:" + prefix : "xmlns"; //$NON-NLS-1$ //$NON-NLS-2$
        String rootElementNamespace = rootElementNamespaceDeclarationName != null ? atts.getValue(rootElementNamespaceDeclarationName) : null;

        String location = null;

        // first we use any 'xsi:schemaLocation' or 'xsi:noNamespaceSchemaLocation' attribute
        // to determine a location
        if (schemaInstancePrefix != null)
        {
          location = atts.getValue(getPrefixedName(schemaInstancePrefix, "noNamespaceSchemaLocation")); //$NON-NLS-1$
          if (location == null)
          {
        	String schemaLoc = atts.getValue(getPrefixedName(schemaInstancePrefix, "schemaLocation"));  //$NON-NLS-1$
            location = getSchemaLocationForNamespace(schemaLoc, rootElementNamespace);
          }
        }
        if (rootElementNamespace == null)
        {
          rootElementNamespace = "";
        }

        location = URIResolverPlugin.createResolver().resolve(baseURI, rootElementNamespace, location);
        location = URIResolverPlugin.createResolver().resolvePhysicalLocation(baseURI, rootElementNamespace, location);
        if (location != null)
        {
          location = URIHelper.addImpliedFileProtocol(location);
        }

        schemaLocationString = location;

        if (location != null)
        {
          isGrammarEncountered  = checkGrammar(location);
        }
      }
    }
    /* (non-Javadoc)
     * @see org.xml.sax.ext.DeclHandler#elementDecl(java.lang.String, java.lang.String)
     */
    public void elementDecl(String name, String model)
    {
      numDTDElements++;
    }

    // The xsiSchemaLocationValue is a list of namespace/location pairs that are separated by whitespace
    // this method walks the list of pairs looking for the specified namespace and returns the associated
    // location.
    //
    protected String getSchemaLocationForNamespace(String xsiSchemaLocationValue, String namespace)
    {
      String result = null;
      if (xsiSchemaLocationValue != null && namespace != null)
      {

        StringTokenizer st = new StringTokenizer(xsiSchemaLocationValue);
        while(st.hasMoreTokens())
        {
          if(st.nextToken().equals(namespace))
          {
            if(st.hasMoreTokens())
            {
              result = st.nextToken();
            }
          }
          else
          {
            if(st.hasMoreTokens())
            {
              st.nextToken();
            }
          }
        }
      }
      return result;
    }
  }


  /**
   * Replace all instances in the string of the old pattern with the new pattern.
   *
   * @param string The string to replace the patterns in.
   * @param oldPattern The old pattern to replace.
   * @param newPattern The pattern used for replacement.
   * @return The modified string with all occurrances of oldPattern replaced by new Pattern.
   */
  protected static String replace(String string, String oldPattern, String newPattern)
  {
    int index = 0;
    while (index != -1)
    {
      index = string.indexOf(oldPattern, index);
      if (index != -1)
      {
        string = string.substring(0, index) + newPattern + string.substring(index + oldPattern.length());
        index = index + oldPattern.length();
      }
    }
    return string;
  }

  private static boolean doCheckGrammar(String location) {
    try {
      URL url = new URL(location);
      URLConnection connection = url.openConnection();
      connection.setConnectTimeout(TWO_MINUTES);
      try (InputStream is = connection.getInputStream()) {
        return true;
      }
    } catch (Exception e) {
      // Do nothing.
    }
    return false;
  }

  private static boolean checkGrammar(String location) {
    GrammarEncounteredResult result = grammarCheckerCache.get(location);
    if (result == null || (System.currentTimeMillis() - result.timestamp) > TWO_MINUTES) {
      result = new GrammarEncounteredResult(doCheckGrammar(location));
      synchronized (grammarCheckerCache) {
    	  grammarCheckerCache.put(location, result);
      }
    }
    return result.result;
  }
}
