/*
** Module   : Soap.java
** Abstract : A SOAP test.
**
** Copyright (c) 2021, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20210827 Created initial version.
** 002 CA  20210902 Added default HTTP method (POST).  Fixed body match failure string.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License
** along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

package com.goldencode.harness.test;

import java.io.*;
import java.util.*;
import java.util.function.*;
import java.util.regex.*;

import javax.xml.parsers.*;
import javax.xml.transform.*;
import javax.xml.transform.dom.*;
import javax.xml.transform.stream.*;
import javax.xml.xpath.*;

import org.w3c.dom.*;
import org.xml.sax.*;

import com.goldencode.harness.*;

/**
 * The configuration for a SOAP test.
 */
public class Soap
extends WebService
{
   /** The document builder factory. */
   private final DocumentBuilderFactory dbf;
   
   /** The XPath factory. */
   private final XPathFactory xpf;
   
   /** All the XML paths in the actual HTTP response. */
   private List<String> allPaths = null;

   /** The parsed XML for the SOAP response.*/
   private Document doc = null;
   
   /**
    * Create a new SOAP test.
    * 
    * @param    cfg
    *           The web test configuration (endpoint, authorization, etc).
    * @param    rc
    *           Object in which to store the details of non-testing failures.
    * @param    pool
    *           The variable pool.
    */
   public Soap(WebSessionSettings cfg, FailureNotification rc, ResourcePool pool)
   {
      super("SOAP", cfg, rc, pool);
      
      dbf = DocumentBuilderFactory.newInstance();
      xpf = XPathFactory.newInstance();
   }

   /**
    * Get the default HTTP method.
    * 
    * @return   <code>POST</code> by default.
    */
   protected String getDefaultMethod()
   {
      return "POST";
   }

   /**
    * Pretty print the specified body.
    * 
    * @param    body
    *           The body.
    *           
    * @return   The formatted body.
    */
   @Override
   protected String prettyPrint(String body)
   {
      if (body == null || body.trim().isEmpty())
      {
         return "";
      }

      try
      {
         Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(body.getBytes()));
         doc.getDocumentElement().normalize();
         
         body = domToString(doc, body.trim().startsWith("<?xml"));
      }
      catch (SAXException                 |
             IOException                  |
             ParserConfigurationException |
             TransformerException e)
      {
         e.printStackTrace();
      }
      
      return body;
   }
   
   /**
    * Get the default content-type for the request.
    * 
    * @return   Always <code>text/xml</code>.
    */
   @Override
   protected String getDefaultContentType()
   {
      return "text/xml";
   }

   /**
    * Prepare the returned HTTP response.
    * 
    * @return   <code>true</code> if the response could be prepared.
    */
   @Override
   protected boolean prepareResponse()
   {
      if (responseBody == null)
      {
         return false;
      }
      
      if (responseBody.isEmpty())
      {
         return true;
      }
      
      try
      {
         doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(responseBody.getBytes()));
         allPaths = listAllPaths(doc);
      }
      catch (SAXException                 |
             IOException                  |
             ParserConfigurationException e)
      {
         throw new ServiceException("Error parsing the response", e);
      }
      catch (XPathExpressionException e)
      {
         throw new ServicePathException("Error resolving body paths", e);
      }
      
      return true;
   }

   /**
    * Release any resources allocated for the HTTP response.
    */
   @Override
   protected void releaseResponse()
   {
      responseBody = null;
      doc = null;
      allPaths = null;
   }

   /**
    * Check if the path exists in the body.
    * 
    * @param    path
    *           The path to check.
    *           
    * @return   <code>true</code> if the path exists.
    */
   @Override
   protected boolean checkPathExists(String path)
   {
      try
      {
         XPath xpath = xpf.newXPath();
         NodeList nl = (NodeList) xpath.compile(path).evaluate(doc, XPathConstants.NODESET);
      
         return nl.getLength() > 0;
      }
      catch (XPathExpressionException e)
      {
         throw new ServicePathException("Could not parse path: " + path, e);
      }
   }

   /**
    * Check the body path for an exact match.
    * 
    * @param    path
    *           The path to check.
    * @param    value
    *           The expected value.
    *           
    * @return   The value, or <code>null</code> if it does not match.
    */
   @Override
   protected Object checkPathValueExact(String path, String value)
   {
      return checkPathValue(doc, path, (txt) -> value != null && txt.equals(value));
   }

   /**
    * Check the body path for a pattern match.
    * 
    * @param    path
    *           The path to check.
    * @param    pattern
    *           The pattern to match.
    *           
    * @return   The value, or <code>null</code> if it does not match.
    */
   @Override
   protected Object checkPathValueRegex(String path, Pattern pattern)
   {
      return checkPathValue(doc, path, (txt) -> 
      {
         if (txt == null)
         {
            return null;
         }
         
         Matcher matcher = pattern.matcher(txt);

         return matcher.matches();
      });
   }

   /**
    * Match the body against the template.
    * <p>
    * All included paths will be retained from the body first, and after that the excluded paths will be 
    * removed.
    * <p>
    * Whatever paths remain must match exactly with the template.
    * 
    * @param    templateBody
    *           The body template.
    * @param    includes
    *           The list of included paths.
    * @param    excludes
    *           The list of excluded paths.
    *           
    * @return   The failure list, or <code>null</code> if it matches.
    */
   @Override
   protected String checkBodyMatch(String templateBody, List<String> includes, List<String> excludes)
   {
      try
      {
         Document tdoc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(templateBody.getBytes()));
         tdoc.getDocumentElement().normalize();
         
         List<String> tpaths = listAllPaths(tdoc);
         
         {
            List<String> allRespPaths = new ArrayList<>(allPaths);
            allRespPaths.removeAll(tpaths);
            if (!allRespPaths.isEmpty())
            {
               return "Response got additional paths from template: " + allRespPaths;
            }
            
            List<String> allTPaths = new ArrayList<>(tpaths);
            allTPaths.removeAll(allPaths);
            if (!allTPaths.isEmpty())
            {
               return "Response is missing template paths: " + allTPaths;
            }
         }
         
         List<String> tipaths = new ArrayList<>();
         for (String path : includes)
         {
            tipaths.addAll(listPaths(tdoc, path));
         }
         List<String> txpaths = new ArrayList<>();
         for (String path : excludes)
         {
            txpaths.addAll(listPaths(tdoc, path));
         }
         
         // keep only the included paths
         if (!includes.isEmpty())
         {
            if (tipaths.isEmpty())
            {
               throw new ServicePathException("Could not resolve include paths from template: " + includes);
            }
            
            tpaths.retainAll(tipaths);
         }

         // remove all excluded paths
         tpaths.removeAll(txpaths);
         
         // compare whatever is left
         String failures = null;
         for (String path : tpaths)
         {
            String tval = getTextContent(tdoc, path);
            String bodyVal = getTextContent(doc, path);
            
            if (!Objects.equals(tval, bodyVal))
            {
               String msg = String.format("Path '%s' expected value '%s' and got value '%s'", path, tval, bodyVal);
               
               failures = (failures == null ? msg : failures + "\n" + msg);
            }
         }
         
         return failures;
      }
      catch (SAXException                 |
             IOException                  |
             ParserConfigurationException e)
      {
         throw new ServiceException("Could not parse template body.", e);
      }
      catch (XPathExpressionException e)
      {
         throw new ServicePathException("Error resolving body paths", e);
      }
   }

   /**
    * Replace the given path in the body with the specified value.
    * 
    * @param    body
    *           The body.
    * @param    path
    *           The path to replace.
    * @param    val
    *           The value to use.
    *           
    * @return   The processed body, or <code>null</code> in case of failure.
    */
   @Override
   protected String replacePath(String body, String path, String val)
   {
      try
      {
         Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(body.getBytes()));
         doc.getDocumentElement().normalize();

         List<String> paths = listPaths(doc, path);
         
         if (paths.isEmpty())
         {
            throw new ServicePathException("Path " + path + " was not found!");
         }
         
         if (paths.size() != 1)
         {
            throw new ServicePathException("More than one path found for " + path + ":" + paths.toString());
         }
         
         String prevTxt = getTextContent(doc, path);
         if (prevTxt == null)
         {
            throw new ServicePathException("Could not retrieve text for " + path + "!");
         }
         
         XPath xpath = xpf.newXPath();
         NodeList nl = (NodeList) xpath.compile(path).evaluate(doc, XPathConstants.NODESET);
         Node n = nl.item(0);
         n.setTextContent(val);
         
         return domToString(doc, body.trim().startsWith("<?xml"));
      }
      catch (SAXException                 |
             IOException                  |
             ParserConfigurationException |
             TransformerException e)
      {
         throw new ServiceException("Error parsing body", e);
      }
      catch (XPathExpressionException e)
      {
         throw new ServicePathException("Error parsing path: " + path, e);
      }
   }
   
   /**
    * Serialize the specified XML document to string.
    * 
    * @param    doc
    *           The XML document to serialize.
    * @param    includeDeclaration
    *           Flag indicating if the XML declaration needs to be included.
    *           
    * @return   The string representation of this document
    * 
    * @throws   TransformerException
    *           If the document could not be serialized.
    */
   private String domToString(Document doc, boolean includeDeclaration) 
   throws TransformerException
   {
      // pretty print
      Transformer transformer = TransformerFactory.newInstance().newTransformer();
      transformer.setOutputProperty(OutputKeys.INDENT, "yes");
      transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
      transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, !includeDeclaration ? "yes" : "no");
   
      // save to string
      StreamResult result = new StreamResult(new StringWriter());
      DOMSource source = new DOMSource(doc);
      transformer.transform(source, result);
      return result.getWriter().toString();
   }

   /**
    * Get the text content for the specified path.
    * <p>
    * Only leaf nodes can have their text content returned.  Otherwise, a {@link ServiceException} is thrown.
    * 
    * @param    doc
    *           The XML document.
    * @param    path
    *           The XPath.
    *           
    * @return   The string value of this node's text content.
    * 
    * @throws   XPathExpressionException
    *           If the path could not be parsed.
    * @throws   ServiceException
    *           If the path does not resolve to a leaf node.
    */
   private String getTextContent(Document doc, String path) 
   throws XPathExpressionException
   {
      XPath xpath = xpf.newXPath();
      NodeList nl = (NodeList) xpath.compile(path).evaluate(doc, XPathConstants.NODESET);
      if (nl.getLength() != 1)
      {
         return null;
      }
      
      Node n = nl.item(0);
      
      nl = n.getChildNodes();
      for (int i = 0; i < nl.getLength(); i++)
      {
         Node nch = nl.item(i);
         if (nch.getNodeType() == Node.ELEMENT_NODE)
         {
            throw new ServiceException("Text content can be retrieved only for leaf nodes: " + path);
         }
      }
      
      return n.getTextContent();
   }
   
   /**
    * Compute the XPath for the specified node.
    * 
    * @param    node
    *           The node.
    *           
    * @return   The path, as resolved from the document element to this node.
    */
   private String getXmlPath(Node node)
   {
      Node parent = node.getParentNode();
      if (parent == null) 
      {
         return ""; // do not emit #document
      }
      
      String name = node.getNodeName();
      name = name.substring(name.indexOf(':') + 1);
      return getXmlPath(parent) + "/" + name;
   }
   
   /**
    * Check if the given path's text content satisfies the specified test.
    * 
    * @param    doc
    *           The XML document.
    * @param    path
    *           The XPath to check.
    * @param    test
    *           The test which needs to be checked.
    *           
    * @return   The node's text content or <code>null</code> if the test is not satisfied.
    * 
    * @throws   ServicePathException
    *           If the path could not be parsed.
    */
   private String checkPathValue(Document doc, String path, Function<String, Boolean> test)
   {
      try
      {
         String txt = getTextContent(doc, path);
         
         return test.apply(txt) ? txt : null;
      }
      catch (XPathExpressionException e)
      {
         throw new ServicePathException("Could not parse path: " + path, e);
      }
   }
   
   /**
    * List all paths for the XML document.
    * 
    * @param    doc
    *           The XML document.
    *          
    * @return   The list of paths originating from root.
    * 
    * @throws   XPathExpressionException
    *           If the XPath could not be parsed.
    */
   private List<String> listAllPaths(Document doc)
   throws XPathExpressionException
   {
      return listPaths(doc, "//*[not(*)]");
   }
   
   /**
    * Resolve all paths from the specified XPath.
    * 
    * @param    doc
    *           The XML document.
    * @param    xp
    *           The XPath.
    * @return   The list of paths originating from this path.
    * 
    * @throws   XPathExpressionException
    *           If the XPath could not be parsed.
    */
   private List<String> listPaths(Document doc, String xp) 
   throws XPathExpressionException
   {
      List<String> paths = new ArrayList<>();
      
      XPath xpath = xpf.newXPath();
      // get all paths
      NodeList nl = (NodeList) xpath.compile(xp).evaluate(doc, XPathConstants.NODESET);
      for (int i = 0; i < nl.getLength(); i++)
      {
         Node n = nl.item(i);
         String path = getXmlPath(n);
         paths.add(path);
      }
      
      return paths;
   }
}
