/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.solr;

import org.apache.noggit.ObjectBuilder;
import org.apache.solr.common.util.StrUtils;

import java.util.*;


public class JSONTestUtil {

  /**
   * Default delta used in numeric equality comparisons for floats and doubles.
   */
  public final static double DEFAULT_DELTA = 1e-5;

  /**
   * comparison using default delta
   * @see #DEFAULT_DELTA
   * @see #match(String,String,double)
   */
  public static String match(String input, String pathAndExpected) throws Exception {
    return match(input, pathAndExpected, DEFAULT_DELTA);
  }

  /**
   * comparison using default delta
   * @see #DEFAULT_DELTA
   * @see #match(String,String,String,double)
   */
  public static String match(String path, String input, String expected) throws Exception {
    return match(path, input, expected, DEFAULT_DELTA);
  }

  /**
   * comparison using default delta
   * @see #DEFAULT_DELTA
   * @see #matchObj(String,Object,Object,double)
   */
  public static String matchObj(String path, Object input, Object expected) throws Exception {
    return matchObj(path,input,expected, DEFAULT_DELTA);
  }

  /**
   * @param input JSON Structure to parse and test against
   * @param pathAndExpected JSON path expression + '==' + expected value
   * @param delta tollerance allowed in comparing float/double values
   */
  public static String match(String input, String pathAndExpected, double delta) throws Exception {
    int pos = pathAndExpected.indexOf("==");
    String path = pos>=0 ? pathAndExpected.substring(0,pos) : null;
    String expected = pos>=0 ? pathAndExpected.substring(pos+2) : pathAndExpected;
    return match(path, input, expected, delta);
  }

  /**
   * @param path JSON path expression
   * @param input JSON Structure to parse and test against
   * @param expected expected value of path
   * @param delta tollerance allowed in comparing float/double values
   */
  public static String match(String path, String input, String expected, double delta) throws Exception {
    Object inputObj = ObjectBuilder.fromJSON(input);
    Object expectObj = ObjectBuilder.fromJSON(expected);
    return matchObj(path, inputObj, expectObj, delta);
  }

  /**
   * @param path JSON path expression
   * @param input JSON Structure
   * @param expected expected JSON Object
   * @param delta tollerance allowed in comparing float/double values
   */
  public static String matchObj(String path, Object input, Object expected, double delta) {
    CollectionTester tester = new CollectionTester(input,delta);
    boolean reversed = path.startsWith("!");
    String positivePath = reversed ? path.substring(1) : path;
    if (!tester.seek(positivePath) ^ reversed) {
      return "Path not found: " + path;
    }
    if (expected != null && (!tester.match(expected) ^ reversed)) {
      return tester.err + " @ " + tester.getPath();
    }
    return null;
  }
}


/** Tests simple object graphs, like those generated by the noggit JSON parser */
class CollectionTester {
  public Object valRoot;
  public Object val;
  public Object expectedRoot;
  public Object expected;
  public double delta;
  public List<Object> path;
  public String err;

  public CollectionTester(Object val, double delta) {
    this.val = val;
    this.valRoot = val;
    this.delta = delta;
    path = new ArrayList<Object>();
  }
  public CollectionTester(Object val) {
    this(val, JSONTestUtil.DEFAULT_DELTA);
  }

  public String getPath() {
    StringBuilder sb = new StringBuilder();
    boolean first=true;
    for (Object seg : path) {
      if (seg==null) break;
      if (!first) sb.append('/');
      else first=false;

      if (seg instanceof Integer) {
        sb.append('[');
        sb.append(seg);
        sb.append(']');
      } else {
        sb.append(seg.toString());
      }
    }
    return sb.toString();
  }

  void setPath(Object lastSeg) {
    path.set(path.size()-1, lastSeg);
  }
  Object popPath() {
    return path.remove(path.size()-1);
  }
  void pushPath(Object lastSeg) {
    path.add(lastSeg);
  }

  void setErr(String msg) {
    err = msg;
  }

  public boolean match(Object expected) {
    this.expectedRoot = expected;
    this.expected = expected;
    return match();
  }

  boolean match() {
    if (expected == val) {
      return true;
    }
    if (expected == null || val == null) {
      setErr("mismatch: '" + expected + "'!='" + val + "'");
      return false;
    }
    if (expected instanceof List) {
      return matchList();
    }
    if (expected instanceof Map) {
      return matchMap();
    }

    // generic fallback
    if (!expected.equals(val)) {

      // make an exception for some numerics
      if ((expected instanceof Integer && val instanceof Long || expected instanceof Long && val instanceof Integer)
          && ((Number)expected).longValue() == ((Number)val).longValue()) {
        return true;
      } else if ((expected instanceof Double || expected instanceof Float) && (val instanceof Double || val instanceof Float)) {
        double a = ((Number)expected).doubleValue();
        double b = ((Number)val).doubleValue();
        if (Double.compare(a,b) == 0) return true;
        if (Math.abs(a-b) < delta) return true;
      }
      setErr("mismatch: '" + expected + "'!='" + val + "'");
      return false;
    }

    // setErr("unknown expected type " + expected.getClass().getName());
    return true;
  }

  boolean matchList() {
    List expectedList = (List)expected;
    List v = asList();
    if (v == null) return false;
    int a = 0;
    int b = 0;
    pushPath(null);
    for (;;) {
      if (a >= expectedList.size() &&  b >=v.size()) {
        break;
      }

      if (a >= expectedList.size() || b >=v.size()) {
        popPath();
        setErr("List size mismatch");
        return false;
      }

      expected = expectedList.get(a);
      val = v.get(b);
      setPath(b);
      if (!match()) return false;

      a++; b++;
    }
    
    popPath();
    return true;
  }

  private static Set<String> reserved = new HashSet<String>(Arrays.asList("_SKIP_","_MATCH_","_ORDERED_","_UNORDERED_"));

  boolean matchMap() {
    Map<String,Object> expectedMap = (Map<String,Object>)expected;
    Map<String,Object> v = asMap();
    if (v == null) return false;

    boolean ordered = false;
    String skipList = (String)expectedMap.get("_SKIP_");
    String matchList = (String)expectedMap.get("_MATCH_");
    Object orderedStr = expectedMap.get("_ORDERED_");
    Object unorderedStr = expectedMap.get("_UNORDERED_");

    if (orderedStr != null) ordered = true;
    if (unorderedStr != null) ordered = false;

    Set<String> match = null;
    if (matchList != null) {
      match = new HashSet(StrUtils.splitSmart(matchList,",",false));
    }

    Set<String> skips = null;
    if (skipList != null) {
      skips = new HashSet(StrUtils.splitSmart(skipList,",",false));
    }

    Set<String> keys = match != null ? match : expectedMap.keySet();
    Set<String> visited = new HashSet<String>();

    Iterator<Map.Entry<String,Object>> iter = ordered ? v.entrySet().iterator() : null;

    int numExpected=0;

    pushPath(null);
    for (String expectedKey : keys) {
      if (reserved.contains(expectedKey)) continue;
      numExpected++;

      setPath(expectedKey);
      if (!v.containsKey(expectedKey)) {
        popPath();
        setErr("expected key '" + expectedKey + "'");
        return false;
      }

      expected = expectedMap.get(expectedKey);

      if (ordered) {
        Map.Entry<String,Object> entry;
        String foundKey;
        for(;;) {
          if (!iter.hasNext()) {
            popPath();
            setErr("expected key '" + expectedKey + "' in ordered map");
            return false;           
          }
          entry = iter.next();
          foundKey = entry.getKey();
          if (skips != null && skips.contains(foundKey))continue;
          if (match != null && !match.contains(foundKey)) continue;
          break;
        }

        if (!entry.getKey().equals(expectedKey)) {
          popPath();          
          setErr("expected key '" + expectedKey + "' instead of '"+entry.getKey()+"' in ordered map");
          return false;
        }
        val = entry.getValue();
      } else {
        if (skips != null && skips.contains(expectedKey)) continue;
        val = v.get(expectedKey);
      }

      if (!match()) return false;
    }

    popPath();

    // now check if there were any extra keys in the value (as long as there wasn't a specific list to include)
    if (match == null) {
      int skipped = 0;
      if (skips != null) {
        for (String skipStr : skips)
          if (v.containsKey(skipStr)) skipped++;
      }
      if (numExpected != (v.size() - skipped)) {
        HashSet<String> set = new HashSet<String>(v.keySet());
        set.removeAll(expectedMap.keySet());
        setErr("unexpected map keys " + set); 
        return false;
      }
    }

    return true;
  }

  public boolean seek(String seekPath) {
    if (path == null) return true;
    if (seekPath.startsWith("/")) {
      seekPath = seekPath.substring(1);
    }
    if (seekPath.endsWith("/")) {
      seekPath = seekPath.substring(0,seekPath.length()-1);
    }
    List<String> pathList = StrUtils.splitSmart(seekPath, "/", false);
    return seek(pathList);
  }

  List asList() {
    // TODO: handle native arrays
    if (val instanceof List) {
      return (List)val;
    }
    setErr("expected List");
    return null;
  }
  
  Map<String,Object> asMap() {
    // TODO: handle NamedList
    if (val instanceof Map) {
      return (Map<String,Object>)val;
    }
    setErr("expected Map");
    return null;
  }

  public boolean seek(List<String> seekPath) {
    if (seekPath.size() == 0) return true;
    String seg = seekPath.get(0);

    if (seg.charAt(0)=='[') {
      List listVal = asList();
      if (listVal==null) return false;

      int arrIdx = Integer.parseInt(seg.substring(1, seg.length()-1));

      if (arrIdx >= listVal.size()) return false;

      val = listVal.get(arrIdx);
      pushPath(arrIdx);
    } else {
      Map<String,Object> mapVal = asMap();
      if (mapVal==null) return false;

      // use containsKey rather than get to handle null values
      if (!mapVal.containsKey(seg)) return false;

      val = mapVal.get(seg);
      pushPath(seg);
    }

    // recurse after removing head of the path
    return seek(seekPath.subList(1,seekPath.size()));
  }



}
