/*
** Module   : Rest.java
** Abstract : A REST test.
**
** Copyright (c) 2021-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 CA  20210827 Created initial version.
** 002 CA  20210902 Fixed body match failure string.
** 003 CA  20220107 The regex match needs to use the string representation of the value.
**                  Fixed cases when the response is not a JSON.
** 004 CA  20220118 checkPathExists will return the path's value, and not true/false.
** 005 CA  20240304 Improved complexity of removePrefixPaths.
** 006 CA  20241010 Improved number check when the JSON body is matched.
*/

/*
** 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.math.BigDecimal;
import java.text.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.*;

import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.goldencode.harness.*;
import com.jayway.jsonpath.*;
import com.jayway.jsonpath.spi.json.*;

/**
 * The configuration for a REST test.
 */
public class Rest
extends WebService
{
   /** The suffix to retrieve all paths. */
   private static final String ALL_PATHS_SUFFIX = "..*";
   
   /** All paths existing in the received HTTP response body. */
   private List<String> allPaths;
   
   /** The configuration to list paths. */
   private Configuration pathsCfg;

   /** The configuration to match path values. */
   private Configuration valsCfg;

   /** The context to list all paths from an expression. */
   private DocumentContext pathsCtx;

   /** The context to get the value of a given path. */
   private DocumentContext valsCtx;
   
   /** Flag indicating if the response was parsed as JSON. */
   private boolean responseIsJson = true;
   
   /**
    * Create a new REST 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 Rest(WebSessionSettings cfg, FailureNotification rc, ResourcePool pool)
   {
      super("REST", cfg, rc, pool);
      
      pathsCfg = Configuration.builder()
                              .options(Option.AS_PATH_LIST, Option.SUPPRESS_EXCEPTIONS)
                              .jsonProvider(new JacksonJsonProvider())
                              .build();
      valsCfg = Configuration.builder()
                             .options(Option.SUPPRESS_EXCEPTIONS)
                             .jsonProvider(new JacksonJsonProvider())
                             .build();
   }

   /**
    * 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
      {
         ObjectMapper mapper = new ObjectMapper();
         Object json = mapper.readValue(body, Object.class);
         body = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json);
      } 
      catch (JsonProcessingException e)
      {
         // ignore
      }
      
      return body;
   }
   
   /**
    * Get the default content-type for the request.
    * 
    * @return   Always <code>application/json</code>.
    */
   @Override
   protected String getDefaultContentType()
   {
      return "application/json";
   }

   /**
    * Prepare the returned HTTP response.
    * 
    * @return   <code>true</code> if the response could be prepared.
    */
   @Override
   protected boolean prepareResponse()
   {
      pathsCtx = null;
      valsCtx = null;
      allPaths = null;

      responseIsJson = true;

      if (responseBody == null)
      {
         responseIsJson = false;

         return false;
      }
      
      if (responseBody.isEmpty())
      {
         responseIsJson = false;
         
         return true;
      }
      
      try
      {
         pathsCtx = JsonPath.using(pathsCfg).parse(responseBody);
         valsCtx = JsonPath.using(valsCfg).parse(responseBody);
         allPaths = pathsCtx.read("$" + ALL_PATHS_SUFFIX);
         allPaths = removePrefixPaths(allPaths);
      }
      catch (InvalidJsonException exc)
      {
         pathsCtx = null;
         valsCtx = null;
         allPaths = null;

         responseIsJson = false;
      }
      
      return true;
   }

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

   /**
    * 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)
   {
      if (!responseIsJson)
      {
         return null;
      }
      
      List<String> paths = pathsCtx.read(path);
      
      if (paths.isEmpty())
      {
         return null;
      }
      
      Object theVal = valsCtx.read(paths.get(0));
      
      return theVal;
   }

   /**
    * 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)
   {
      if (!responseIsJson)
      {
         return null;
      }

      List<String> paths = pathsCtx.read(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());
      }

      Object theVal = valsCtx.read(paths.get(0));
      if (theVal == null)
      {
         // the value was not found in the actual response
         return null;
      }
      
      try
      {
         Class<?> type = theVal.getClass();
         
         // convert the value to the data-type as parsed from the actual response
         Object expected = value;
         if (type == Boolean.class)
         {
            expected = Boolean.parseBoolean(value);
         }
         else if (type == Integer.class)
         {
            expected = Integer.parseInt(value);
         }
         else if (type == Long.class)
         {
            expected = Long.parseLong(value);
         }
         else if (type == Double.class)
         {
            expected = Double.parseDouble(value);
         }
         else if (type == Float.class)
         {
            expected = Float.parseFloat(value);
         }
         else if (type == String.class)
         {
            expected = value;
         }
         else if (type == Date.class)
         {
            SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
            expected = sdf.parse(value);
         }
         else
         {
            throw new ServiceException("Unexpected type: " + type + " for path " + path);
         }

         return expected.equals(theVal) ? theVal : null;
      }
      catch (Throwable t)
      {
         throw new ServiceException("Could not parse value " + value + " for path " + path);
      }
   }

   /**
    * 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)
   {
      if (!responseIsJson)
      {
         return null;
      }

      List<String> paths = pathsCtx.read(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 theVal = valsCtx.read(path).toString();
      Matcher matcher = pattern.matcher(theVal);

      return matcher.matches() ? theVal : null;
   }

   /**
    * 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)
   throws InvalidJsonException
   {
      if (!responseIsJson)
      {
         if (templateBody.equals(responseBody))
         {
            return null;
         }
         
         return "Response body does not match:\n" + templateBody;
      }
      
      DocumentContext tPathCtx = JsonPath.using(pathsCfg).parse(templateBody);
      DocumentContext tValCtx = JsonPath.using(valsCfg).parse(templateBody);
      
      List<String> tpaths = tPathCtx.read("$" + ALL_PATHS_SUFFIX);
      tpaths = removePrefixPaths(tpaths);
      
      {
         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 = readAllPaths(tPathCtx, includes);
      List<String> txpaths = readAllPaths(tPathCtx, excludes);
      
      // 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)
      {
         Object tval = tValCtx.read(path);
         Object bodyVal = valsCtx.read(path);
         
         boolean eq = Objects.equals(tval, bodyVal);
         if (!eq && tval instanceof Number && bodyVal instanceof Number)
         {
            BigDecimal b1 = new BigDecimal(((Number) tval).doubleValue());
            BigDecimal b2 = new BigDecimal(((Number) bodyVal).doubleValue());
            eq = b1.compareTo(b2) == 0;
         }
         
         if (!eq)
         {
            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;
   }

   /**
    * 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)
   throws InvalidJsonException
   {
      DocumentContext pathCtx = JsonPath.using(pathsCfg).parse(body);
      List<String> paths = pathCtx.read(path);
      if (paths.isEmpty())
      {
         throw new ServicePathException("Path " + path + " was not found!");
      }
      DocumentContext valCtx = JsonPath.using(valsCfg).parse(body);
      valCtx.set(path, val);
      body = valCtx.jsonString();
      
      return body;
   }
   
   /**
    * Read all the paths (leaves) from the specified JSON context.
    * 
    * @param    ctx
    *           The JSON document context.
    * @param    paths
    *           The list of paths.
    *           
    * @return   The list of leaf paths.
    */
   private List<String> readAllPaths(DocumentContext ctx, List<String> paths)
   {
      List<String> res = new ArrayList<>();
      for (String path : paths)
      {
         res.addAll(ctx.read(path));
      }
      res = removePrefixPaths(res);
      
      return res;
   }

   /**
    * Remove all prefix paths from the given list.
    * <p>
    * Only leaf paths will be returned.
    * 
    * @param    paths
    *           The list of paths.
    *           
    * @return   The leaves.
    */
   protected List<String> removePrefixPaths(List<String> paths)
   {
      if (paths.isEmpty() || paths.size() == 1)
      {
         return paths;
      }
      
      Collections.sort(paths);
      
      // count all prefix path appearances
      Map<String, AtomicInteger> counters = new TreeMap<>();
      for (String path : paths)
      {
         String p = path;
         p = p.substring(1); // remove leading $
         String[] entries = p.split("[\\[\\]]");
         String prefix = "$";
         for (int i = 0; i < entries.length; i++)
         {
            // skip empty entries
            if (entries[i].isEmpty()) 
            {
               continue;
            }
            prefix = prefix + "[" + entries[i] + "]";
            AtomicInteger counter = counters.computeIfAbsent(prefix, (k) -> new AtomicInteger(0));
            counter.incrementAndGet();
         }
      }
      
      // keep everything which is not a parent
      List<String> res = new ArrayList<>();
      for (Map.Entry<String, AtomicInteger> entry : counters.entrySet())
      {
         if (entry.getValue().get() == 1)
         {
            res.add(entry.getKey());
         }
      }
      
      return res;
   }
}
