/*
 * SonarQube
 * Copyright (C) 2009-2024 SonarSource SA
 * mailto:info AT sonarsource DOT com
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
package org.sonar.test;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;

import static java.util.Collections.synchronizedSet;

@ThreadSafe
class JsonComparison {

  private boolean strictTimezone = false;
  private boolean strictArrayOrder = false;
  private Set<String> ignoredFields = synchronizedSet(new HashSet<>());

  boolean isStrictTimezone() {
    return strictTimezone;
  }

  JsonComparison withTimezone() {
    this.strictTimezone = true;
    return this;
  }

  boolean isStrictArrayOrder() {
    return strictArrayOrder;
  }

  JsonComparison withStrictArrayOrder() {
    this.strictArrayOrder = true;
    return this;
  }

  JsonComparison setIgnoredFields(String... ignoredFields) {
    Collections.addAll(this.ignoredFields, ignoredFields);
    return this;
  }

  boolean areSimilar(String expected, String actual) {
    Object expectedJson = parse(expected);
    Object actualJson = parse(actual);
    return compare(expectedJson, actualJson);
  }

  private static Object parse(String s) {
    try {
      JSONParser parser = new JSONParser();
      return parser.parse(s);
    } catch (Exception e) {
      throw new IllegalStateException("Invalid JSON: " + s, e);
    }
  }

  private boolean compare(@Nullable Object expectedObject, @Nullable Object actualObject) {
    if (expectedObject == null) {
      return actualObject == null;
    }
    if (actualObject == null) {
      // expected non-null, got null
      return false;
    }
    if (expectedObject.getClass() != actualObject.getClass()) {
      return false;
    }
    if (expectedObject instanceof JSONArray jsonArray) {
      return compareArrays(jsonArray, (JSONArray) actualObject);
    }
    if (expectedObject instanceof JSONObject jsonObject) {
      return compareObjects(jsonObject, (JSONObject) actualObject);
    }
    if (expectedObject instanceof String string) {
      return compareStrings(string, (String) actualObject);
    }
    if (expectedObject instanceof Number number) {
      return compareNumbers(number, (Number) actualObject);
    }
    return compareBooleans((Boolean) expectedObject, (Boolean) actualObject);
  }

  private static boolean compareBooleans(Boolean expected, Boolean actual) {
    return expected.equals(actual);
  }

  private static boolean compareNumbers(Number expected, Number actual) {
    double d1 = expected.doubleValue();
    double d2 = actual.doubleValue();
    if (Double.compare(d1, d2) == 0) {
      return true;
    }
    return Math.abs(d1 - d2) <= 0.0000001;
  }

  private boolean compareStrings(String expected, String actual) {
    if (!strictTimezone) {
      // two instants with different timezones are considered as identical (2015-01-01T13:00:00+0100 and 2015-01-01T12:00:00+0000)
      Date expectedDate = tryParseDate(expected);
      Date actualDate = tryParseDate(actual);
      if (expectedDate != null && actualDate != null) {
        return expectedDate.getTime() == actualDate.getTime();
      }
    }
    return expected.equals(actual);
  }

  private boolean compareArrays(JSONArray expected, JSONArray actual) {
    if (strictArrayOrder) {
      return compareArraysByStrictOrder(expected, actual);
    }
    return compareArraysByLenientOrder(expected, actual);
  }

  private boolean compareArraysByStrictOrder(JSONArray expected, JSONArray actual) {
    if (expected.size() != actual.size()) {
      return false;
    }

    for (int index = 0; index < expected.size(); index++) {
      Object expectedElt = expected.get(index);
      Object actualElt = actual.get(index);
      if (!compare(expectedElt, actualElt)) {
        return false;
      }
    }
    return true;
  }

  private boolean compareArraysByLenientOrder(JSONArray expected, JSONArray actual) {
    if (expected.size() > actual.size()) {
      return false;
    }

    List remainingActual = new ArrayList(actual);
    for (Object expectedElement : expected) {
      // element can be null
      boolean found = false;
      for (Object actualElement : remainingActual) {
        if (compare(expectedElement, actualElement)) {
          found = true;
          remainingActual.remove(actualElement);
          break;
        }
      }
      if (!found) {
        return false;
      }
    }
    return remainingActual.isEmpty();
  }

  private boolean compareObjects(JSONObject expectedMap, JSONObject actualMap) {
    // each key-value of expected map must exist in actual map
    for (Map.Entry<Object, Object> expectedEntry : (Set<Map.Entry<Object, Object>>) expectedMap.entrySet()) {
      Object key = expectedEntry.getKey();
      if (shouldIgnoreField(key)) {
        continue;
      }
      if (!actualMap.containsKey(key)) {
        return false;
      }
      if (!compare(expectedEntry.getValue(), actualMap.get(key))) {
        return false;
      }
    }
    return true;
  }

  private boolean shouldIgnoreField(Object key) {
    return key instanceof String && ignoredFields.contains(key);
  }

  @CheckForNull
  private static Date tryParseDate(String s) {
    try {
      return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(s);
    } catch (ParseException ignored) {
      // not a datetime
      return null;
    }
  }
}
