/*
** Module   : Soap.java
** Abstract : A SOAP test.
**
** Copyright (c) 2021-2022, 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.
** 003 CA  20220107 Fixed cases when the text in the element node is on multiple elements. 
** 004 CA  20220118 checkPathExists will return the path's value, and not true/false.
**                  Performance improvement when comparing the body paths, to avoid XPath on every node.
*/

/*
** 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 LinkedHashMap<String, List<Node>> 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 Object checkPathExists(String path)
   {
      try
      {
         LinkedHashMap<String, List<Node>> paths = listPaths(doc, path);
         if (!paths.isEmpty())
         {
            String[] texts = getTextContent(paths.get(path));
            return texts == null || texts.length != 1 ? null : texts[0];
         }
         else
         {
            return null;
         }
      }
      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();
         
         LinkedHashMap<String, List<Node>> tpaths = listAllPaths(tdoc);
         
         {
            List<String> allRespPaths = new ArrayList<>(allPaths.keySet());
            allRespPaths.removeAll(tpaths.keySet());
            if (!allRespPaths.isEmpty())
            {
               return "Response got additional paths from template: " + allRespPaths;
            }
            
            List<String> allTPaths = new ArrayList<>(tpaths.keySet());
            allTPaths.removeAll(allPaths.keySet());
            if (!allTPaths.isEmpty())
            {
               return "Response is missing template paths: " + allTPaths;
            }
         }
         
         Map<String, List<Node>> tipaths = new LinkedHashMap<>();
         for (String path : includes)
         {
            tipaths.putAll(listPaths(tdoc, path));
         }
         Map<String, List<Node>> txpaths = new LinkedHashMap<>();
         for (String path : excludes)
         {
            txpaths.putAll(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.keySet().retainAll(tipaths.keySet());
         }

         // remove all excluded paths
         tpaths.keySet().removeAll(txpaths.keySet());
         
         // TODO: check namespaces for the entire DOM
         // TODO: check attributes for the entire DOM
         
         // compare whatever is left
         String failures = null;
         for (Map.Entry<String, List<Node>> epath : tpaths.entrySet())
         {
            String[] tval = getTextContent(epath.getValue());
            String[] bodyVal = getTextContent(allPaths.get(epath.getKey()));
            
            if (tval.length != bodyVal.length)
            {
               String msg = String.format("Path '%s' expected %d nodes and got %d nodes", 
                                          epath.getKey(), 
                                          tval.length, 
                                          bodyVal.length);
               
               failures = (failures == null ? msg : failures + "\n" + msg);
            }
            else
            {
               for (int i = 0; i < tval.length; i++)
               {
                  if (!Objects.equals(tval[i], bodyVal[i]))
                  {
                     String msg = String.format("Path '%s[%d]' expected value '%s' and got value '%s'", 
                                                epath.getKey(),
                                                i,
                                                tval[i], 
                                                bodyVal[i]);

                     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();

         LinkedHashMap<String, List<Node>> paths = listPaths(doc, path);
         
         if (paths.isEmpty())
         {
            throw new ServicePathException("Path " + path + " was not found!");
         }
         
         if (paths.size() != 1 || paths.get(path).size() != 1)
         {
            throw new ServicePathException("More than one path found for " + path + ":" + paths.toString());
         }
         
         List<Node> lpath = paths.get(path);
         String[] prevTxt = getTextContent(lpath);
         if (prevTxt == null || prevTxt.length == 0)
         {
            throw new ServicePathException("Could not retrieve text for " + path + "!");
         }
         
         lpath.get(0).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 nodes.
    * 
    * @param    npath
    *           The list of nodes.
    *           
    * @return   The string value of the nodes' 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(List<Node> npath) 
   throws XPathExpressionException
   {
      List<String> texts = new ArrayList<>();
      for (Node n : npath)
      {
         List<String> res = getTextContent(n);
         texts.addAll(res);
      }
      
      return texts.toArray(new String[texts.size()]);
   }
   
   /**
    * Get the text content for the specified node.
    * 
    * @param    n
    *           The node.
    *           
    * @return   The text content.
    */
   private List<String> getTextContent(Node n)
   {
      List<String> res = new ArrayList<>();
      
      String txt = n.getTextContent();
      res.add(txt);
      
      NodeList nl = n.getChildNodes();
      for (int i = 0; i < nl.getLength(); i++)
      {
         Node nch = nl.item(i);
         if (nch instanceof Element)
         {
            // always trim the text in case of non-leaf nodes
            txt = txt.trim();
            
            res.addAll(getTextContent(nch));
         }
      }
      
      return res;
   }
   
   /**
    * 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
      {
         LinkedHashMap<String, List<Node>> paths = listPaths(doc, path);
         String[] txt = getTextContent(paths.get(path));
         
         return txt == null || txt.length == 0 ? null : test.apply(txt[0]) ? txt[0] : 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 LinkedHashMap<String, List<Node>> 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 LinkedHashMap<String, List<Node>> listPaths(Document doc, String xp) 
   throws XPathExpressionException
   {
      LinkedHashMap<String, List<Node>> paths = new LinkedHashMap<>();
      
      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);
         List<Node> l = paths.computeIfAbsent(path, (k) -> new ArrayList<>());
         l.add(n);
      }
      
      return paths;
   }
}
