/*
 * 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.util;

import java.text.ParseException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;

import org.apache.solr.SolrTestCaseJ4;

import static org.apache.solr.util.DateMathParser.UTC;

/**
 * Tests that the functions in DateMathParser
 */
public class DateMathParserTest extends SolrTestCaseJ4 {

  /**
   * A formatter for specifying every last nuance of a Date for easy
   * reference in assertion statements
   */
  private DateTimeFormatter fmt;

  /**
   * A parser for reading in explicit dates that are convenient to type
   * in a test
   */
  private DateTimeFormatter parser;

  public DateMathParserTest() {
    fmt = DateTimeFormatter.ofPattern("G yyyyy MM ww W D dd F E a HH hh mm ss SSS z Z", Locale.ROOT)
        .withZone(ZoneOffset.UTC);

    parser = DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneOffset.UTC); // basically without the 'Z'
  }

  /** MACRO: Round: parses s, rounds with u, fmts */
  protected String r(String s, String u) throws Exception {
    Date dt = DateMathParser.parseMath(null, s + "Z/" + u);
    return fmt.format(dt.toInstant());
  }
  
  /** MACRO: Add: parses s, adds v u, fmts */
  protected String a(String s, int v, String u) throws Exception {
    char sign = v >= 0 ? '+' : '-';
    Date dt = DateMathParser.parseMath(null, s + 'Z' + sign + Math.abs(v) + u);
    return fmt.format(dt.toInstant());
  }

  /** MACRO: Expected: parses s, fmts */
  protected String e(String s) throws Exception {
    return fmt.format(parser.parse(s, Instant::from));
  }

  protected void assertRound(String e, String i, String u) throws Exception {
    String ee = e(e);
    String rr = r(i,u);
    assertEquals(ee + " != " + rr + " round:" + i + ":" + u, ee, rr);
  }

  protected void assertAdd(String e, String i, int v, String u)
    throws Exception {
    
    String ee = e(e);
    String aa = a(i,v,u);
    assertEquals(ee + " != " + aa + " add:" + i + "+" + v + ":" + u, ee, aa);
  }

  protected void assertMath(String e, DateMathParser p, String i)
    throws Exception {
    
    String ee = e(e);
    String aa = fmt.format(p.parseMath(i).toInstant());
    assertEquals(ee + " != " + aa + " math:" +
                 parser.format(p.getNow().toInstant()) + ":" + i, ee, aa);
  }

  private void setNow(DateMathParser p, String text) {
    p.setNow(Date.from(parser.parse(text, Instant::from)));
  }
  
  public void testCalendarUnitsConsistency() throws Exception {
    String input = "1234-07-04T12:08:56.235";
    for (String u : DateMathParser.CALENDAR_UNITS.keySet()) {
      try {
        r(input, u);
      } catch (IllegalStateException e) {
        assertNotNull("no logic for rounding: " + u, e);
      }
      try {
        a(input, 1, u);
      } catch (IllegalStateException e) {
        assertNotNull("no logic for rounding: " + u, e);
      }
    }
  }
  
  public void testRound() throws Exception {
    
    String input = "1234-07-04T12:08:56.235";
    
    assertRound("1234-07-04T12:08:56.000", input, "SECOND");
    assertRound("1234-07-04T12:08:00.000", input, "MINUTE");
    assertRound("1234-07-04T12:00:00.000", input, "HOUR");
    assertRound("1234-07-04T00:00:00.000", input, "DAY");
    assertRound("1234-07-01T00:00:00.000", input, "MONTH");
    assertRound("1234-01-01T00:00:00.000", input, "YEAR");

  }

  public void testAddZero() throws Exception {
    
    String input = "1234-07-04T12:08:56.235";
    
    for (String u : DateMathParser.CALENDAR_UNITS.keySet()) {
      assertAdd(input, input, 0, u);
    }
  }

  
  public void testAdd() throws Exception {
    
    String input = "1234-07-04T12:08:56.235";
    
    assertAdd("1234-07-04T12:08:56.236", input, 1, "MILLISECOND");
    assertAdd("1234-07-04T12:08:57.235", input, 1, "SECOND");
    assertAdd("1234-07-04T12:09:56.235", input, 1, "MINUTE");
    assertAdd("1234-07-04T13:08:56.235", input, 1, "HOUR");
    assertAdd("1234-07-05T12:08:56.235", input, 1, "DAY");
    assertAdd("1234-08-04T12:08:56.235", input, 1, "MONTH");
    assertAdd("1235-07-04T12:08:56.235", input, 1, "YEAR");
    
  }
  
  public void testParseStatelessness() throws Exception {

    DateMathParser p = new DateMathParser(UTC);
    setNow(p, "1234-07-04T12:08:56.235");

    String e = fmt.format(p.parseMath("").toInstant());
    
    Date trash = p.parseMath("+7YEARS");
    trash = p.parseMath("/MONTH");
    trash = p.parseMath("-5DAYS+20MINUTES");
    Thread.currentThread();
    Thread.sleep(5);
    
    String a =fmt.format(p.parseMath("").toInstant());
    assertEquals("State of DateMathParser changed", e, a);
  }

  public void testParseMath() throws Exception {

    DateMathParser p = new DateMathParser(UTC);
    setNow(p, "1234-07-04T12:08:56.235");

    // No-Op
    assertMath("1234-07-04T12:08:56.235", p, "");
    
    // simple round
    assertMath("1234-07-04T12:08:56.235", p, "/MILLIS"); // no change
    assertMath("1234-07-04T12:08:56.000", p, "/SECOND");
    assertMath("1234-07-04T12:08:00.000", p, "/MINUTE");
    assertMath("1234-07-04T12:00:00.000", p, "/HOUR");
    assertMath("1234-07-04T00:00:00.000", p, "/DAY");
    assertMath("1234-07-01T00:00:00.000", p, "/MONTH");
    assertMath("1234-01-01T00:00:00.000", p, "/YEAR");

    // simple addition
    assertMath("1234-07-04T12:08:56.236", p, "+1MILLISECOND");
    assertMath("1234-07-04T12:08:57.235", p, "+1SECOND");
    assertMath("1234-07-04T12:09:56.235", p, "+1MINUTE");
    assertMath("1234-07-04T13:08:56.235", p, "+1HOUR");
    assertMath("1234-07-05T12:08:56.235", p, "+1DAY");
    assertMath("1234-08-04T12:08:56.235", p, "+1MONTH");
    assertMath("1235-07-04T12:08:56.235", p, "+1YEAR");

    // simple subtraction
    assertMath("1234-07-04T12:08:56.234", p, "-1MILLISECOND");
    assertMath("1234-07-04T12:08:55.235", p, "-1SECOND");
    assertMath("1234-07-04T12:07:56.235", p, "-1MINUTE");
    assertMath("1234-07-04T11:08:56.235", p, "-1HOUR");
    assertMath("1234-07-03T12:08:56.235", p, "-1DAY");
    assertMath("1234-06-04T12:08:56.235", p, "-1MONTH");
    assertMath("1233-07-04T12:08:56.235", p, "-1YEAR");

    // simple '+/-'
    assertMath("1234-07-04T12:08:56.235", p, "+1MILLISECOND-1MILLISECOND");
    assertMath("1234-07-04T12:08:56.235", p, "+1SECOND-1SECOND");
    assertMath("1234-07-04T12:08:56.235", p, "+1MINUTE-1MINUTE");
    assertMath("1234-07-04T12:08:56.235", p, "+1HOUR-1HOUR");
    assertMath("1234-07-04T12:08:56.235", p, "+1DAY-1DAY");
    assertMath("1234-07-04T12:08:56.235", p, "+1MONTH-1MONTH");
    assertMath("1234-07-04T12:08:56.235", p, "+1YEAR-1YEAR");

    // simple '-/+'
    assertMath("1234-07-04T12:08:56.235", p, "-1MILLISECOND+1MILLISECOND");
    assertMath("1234-07-04T12:08:56.235", p, "-1SECOND+1SECOND");
    assertMath("1234-07-04T12:08:56.235", p, "-1MINUTE+1MINUTE");
    assertMath("1234-07-04T12:08:56.235", p, "-1HOUR+1HOUR");
    assertMath("1234-07-04T12:08:56.235", p, "-1DAY+1DAY");
    assertMath("1234-07-04T12:08:56.235", p, "-1MONTH+1MONTH");
    assertMath("1234-07-04T12:08:56.235", p, "-1YEAR+1YEAR");

    // more complex stuff
    assertMath("1233-07-04T12:08:56.236", p, "+1MILLISECOND-1YEAR");
    assertMath("1233-07-04T12:08:57.235", p, "+1SECOND-1YEAR");
    assertMath("1233-07-04T12:09:56.235", p, "+1MINUTE-1YEAR");
    assertMath("1233-07-04T13:08:56.235", p, "+1HOUR-1YEAR");
    assertMath("1233-07-05T12:08:56.235", p, "+1DAY-1YEAR");
    assertMath("1233-08-04T12:08:56.235", p, "+1MONTH-1YEAR");
    assertMath("1233-07-04T12:08:56.236", p, "-1YEAR+1MILLISECOND");
    assertMath("1233-07-04T12:08:57.235", p, "-1YEAR+1SECOND");
    assertMath("1233-07-04T12:09:56.235", p, "-1YEAR+1MINUTE");
    assertMath("1233-07-04T13:08:56.235", p, "-1YEAR+1HOUR");
    assertMath("1233-07-05T12:08:56.235", p, "-1YEAR+1DAY");
    assertMath("1233-08-04T12:08:56.235", p, "-1YEAR+1MONTH");
    assertMath("1233-07-01T00:00:00.000", p, "-1YEAR+1MILLISECOND/MONTH");
    assertMath("1233-07-04T00:00:00.000", p, "-1YEAR+1SECOND/DAY");
    assertMath("1233-07-04T00:00:00.000", p, "-1YEAR+1MINUTE/DAY");
    assertMath("1233-07-04T13:00:00.000", p, "-1YEAR+1HOUR/HOUR");
    assertMath("1233-07-05T12:08:56.000", p, "-1YEAR+1DAY/SECOND");
    assertMath("1233-08-04T12:08:56.000", p, "-1YEAR+1MONTH/SECOND");

    // "tricky" cases
    setNow(p, "2006-01-31T17:09:59.999");
    assertMath("2006-02-28T17:09:59.999", p, "+1MONTH");
    assertMath("2008-02-29T17:09:59.999", p, "+25MONTH");
    assertMath("2006-02-01T00:00:00.000", p, "/MONTH+35DAYS/MONTH");
    assertMath("2006-01-31T17:10:00.000", p, "+3MILLIS/MINUTE");
  }

  public void testParseMathTz() throws Exception {

    final String PLUS_TZS = "America/Los_Angeles";
    final String NEG_TZS = "Europe/Paris";
    
    assumeTrue("Test requires JVM to know about about TZ: " + PLUS_TZS,
               TimeZoneUtils.KNOWN_TIMEZONE_IDS.contains(PLUS_TZS)); 
    assumeTrue("Test requires JVM to know about about TZ: " + NEG_TZS,
               TimeZoneUtils.KNOWN_TIMEZONE_IDS.contains(NEG_TZS)); 

    // US, Positive Offset with DST

    TimeZone tz = TimeZone.getTimeZone(PLUS_TZS);
    DateMathParser p = new DateMathParser(tz);

    setNow(p, "2001-07-04T12:08:56.235");

    // No-Op
    assertMath("2001-07-04T12:08:56.235", p, "");
    assertMath("2001-07-04T12:08:56.235", p, "/MILLIS");

    assertMath("2001-07-04T12:08:56.000", p, "/SECOND");
    assertMath("2001-07-04T12:08:00.000", p, "/MINUTE");
    assertMath("2001-07-04T12:00:00.000", p, "/HOUR");
    assertMath("2001-07-04T07:00:00.000", p, "/DAY");
    assertMath("2001-07-01T07:00:00.000", p, "/MONTH");
    // no DST in jan
    assertMath("2001-01-01T08:00:00.000", p, "/YEAR");
    // no DST in nov 2001
    assertMath("2001-11-04T08:00:00.000", p, "+4MONTH/DAY");
    // yes DST in nov 2010
    assertMath("2010-11-04T07:00:00.000", p, "+9YEAR+4MONTH/DAY");

    // France, Negative Offset with DST

    tz = TimeZone.getTimeZone(NEG_TZS);
    p = new DateMathParser(tz);
    setNow(p, "2001-07-04T12:08:56.235");

    assertMath("2001-07-04T12:08:56.000", p, "/SECOND");
    assertMath("2001-07-04T12:08:00.000", p, "/MINUTE");
    assertMath("2001-07-04T12:00:00.000", p, "/HOUR");
    assertMath("2001-07-03T22:00:00.000", p, "/DAY");
    assertMath("2001-06-30T22:00:00.000", p, "/MONTH");
    // no DST in dec
    assertMath("2000-12-31T23:00:00.000", p, "/YEAR");
    // no DST in nov
    assertMath("2001-11-03T23:00:00.000", p, "+4MONTH/DAY");

  } 
 
  public void testParseMathExceptions() throws Exception {
    
    DateMathParser p = new DateMathParser(UTC);
    setNow(p, "1234-07-04T12:08:56.235");
    
    Map<String,Integer> badCommands = new HashMap<>();
    badCommands.put("/", 1);
    badCommands.put("+", 1);
    badCommands.put("-", 1);
    badCommands.put("/BOB", 1);
    badCommands.put("+SECOND", 1);
    badCommands.put("-2MILLI/", 4);
    badCommands.put(" +BOB", 0);
    badCommands.put("+2SECONDS ", 3);
    badCommands.put("/4", 1);
    badCommands.put("?SECONDS", 0);

    for (String command : badCommands.keySet()) {
      try {
        Date out = p.parseMath(command);
        fail("Didn't generate SyntaxError for: " + command);
      } catch (ParseException e) {
        assertEquals("Wrong pos for: " + command + " => " + e.getMessage(),
                     badCommands.get(command).intValue(), e.getErrorOffset());

      }
    }
    
  }

  /*
  PARSING / FORMATTING (without date math)  Formerly in DateFieldTest.
   */


  public void testFormatter() {
    assertFormat("1995-12-31T23:59:59.999Z", 820454399999l);
    assertFormat("1995-12-31T23:59:59.990Z", 820454399990l);
    assertFormat("1995-12-31T23:59:59.900Z", 820454399900l);
    assertFormat("1995-12-31T23:59:59Z", 820454399000l);

    // just after epoch
    assertFormat("1970-01-01T00:00:00.005Z", 5L);
    assertFormat("1970-01-01T00:00:00Z",     0L);
    assertFormat("1970-01-01T00:00:00.370Z",  370L);
    assertFormat("1970-01-01T00:00:00.900Z",   900L);

    // well after epoch
    assertFormat("1999-12-31T23:59:59.005Z", 946684799005L);
    assertFormat("1999-12-31T23:59:59Z",     946684799000L);
    assertFormat("1999-12-31T23:59:59.370Z",  946684799370L);
    assertFormat("1999-12-31T23:59:59.900Z",   946684799900L);

    // waaaay after epoch  ('+' is required for more than 4 digits in a year)
    assertFormat("+12345-12-31T23:59:59.005Z", 327434918399005L);
    assertFormat("+12345-12-31T23:59:59Z",     327434918399000L);
    assertFormat("+12345-12-31T23:59:59.370Z",  327434918399370L);
    assertFormat("+12345-12-31T23:59:59.900Z",   327434918399900L);

    // well before epoch
    assertFormat("0299-12-31T23:59:59Z",     -52700112001000L);
    assertFormat("0299-12-31T23:59:59.123Z", -52700112000877L);
    assertFormat("0299-12-31T23:59:59.090Z",  -52700112000910L);

    // BC (negative years)
    assertFormat("-12021-12-01T02:02:02Z", Instant.parse("-12021-12-01T02:02:02Z").toEpochMilli());
  }

  private void assertFormat(final String expected, final long millis) {
    assertEquals(expected, Instant.ofEpochMilli(millis).toString()); // assert same as ISO_INSTANT
    assertEquals(millis, DateMathParser.parseMath(null, expected).getTime()); // assert DMP has same result
  }

  /**
   * Using dates in the canonical format, verify that parsing+formatting
   * is an identify function
   */
  public void testRoundTrip() throws Exception {
    // NOTE: the 2nd arg is what the round trip result looks like (may be null if same as input)

    assertParseFormatEquals("1995-12-31T23:59:59.999666Z",  "1995-12-31T23:59:59.999Z"); // beyond millis is truncated
    assertParseFormatEquals("1995-12-31T23:59:59.999Z",     "1995-12-31T23:59:59.999Z");
    assertParseFormatEquals("1995-12-31T23:59:59.99Z",      "1995-12-31T23:59:59.990Z");
    assertParseFormatEquals("1995-12-31T23:59:59.9Z",       "1995-12-31T23:59:59.900Z");
    assertParseFormatEquals("1995-12-31T23:59:59Z",         "1995-12-31T23:59:59Z");

    // here the input isn't in the canonical form, but we should be forgiving
    assertParseFormatEquals("1995-12-31T23:59:59.990Z", "1995-12-31T23:59:59.990Z");
    assertParseFormatEquals("1995-12-31T23:59:59.900Z", "1995-12-31T23:59:59.900Z");
    assertParseFormatEquals("1995-12-31T23:59:59.90Z",  "1995-12-31T23:59:59.900Z");
    assertParseFormatEquals("1995-12-31T23:59:59.000Z", "1995-12-31T23:59:59Z");
    assertParseFormatEquals("1995-12-31T23:59:59.00Z",  "1995-12-31T23:59:59Z");
    assertParseFormatEquals("1995-12-31T23:59:59.0Z",   "1995-12-31T23:59:59Z");

    // kind of kludgy, but we have other tests for the actual date math
    //assertParseFormatEquals("NOW/DAY", p.parseMath("/DAY").toInstant().toString());

    // as of Solr 1.3
    assertParseFormatEquals("1995-12-31T23:59:59Z/DAY", "1995-12-31T00:00:00Z");
    assertParseFormatEquals("1995-12-31T23:59:59.123Z/DAY", "1995-12-31T00:00:00Z");
    assertParseFormatEquals("1995-12-31T23:59:59.123999Z/DAY", "1995-12-31T00:00:00Z");

    // typical dates, various precision  (0,1,2,3 digits of millis)
    assertParseFormatEquals("1995-12-31T23:59:59.987Z", null);
    assertParseFormatEquals("1995-12-31T23:59:59.98Z", "1995-12-31T23:59:59.980Z");//add 0 ms
    assertParseFormatEquals("1995-12-31T23:59:59.9Z",  "1995-12-31T23:59:59.900Z");//add 00 ms
    assertParseFormatEquals("1995-12-31T23:59:59Z", null);
    assertParseFormatEquals("1976-03-06T03:06:00Z", null);
    assertParseFormatEquals("1995-12-31T23:59:59.987654Z", "1995-12-31T23:59:59.987Z");//truncate nanoseconds off

    // dates with atypical years
    assertParseFormatEquals("0001-01-01T01:01:01Z", null);
    assertParseFormatEquals("+12021-12-01T03:03:03Z", null);

    assertParseFormatEquals("0000-04-04T04:04:04Z", null); // note: 0 AD is also known as 1 BC

    // dates with negative years (BC)
    assertParseFormatEquals("-0005-05-05T05:05:05Z", null);
    assertParseFormatEquals("-2021-12-01T04:04:04Z", null);
    assertParseFormatEquals("-12021-12-01T02:02:02Z", null);
  }

  public void testParseLenient() throws Exception {
    // dates that only parse thanks to lenient mode of DateTimeFormatter
    assertParseFormatEquals("10995-12-31T23:59:59.990Z", "+10995-12-31T23:59:59.990Z"); // missing '+' 5 digit year
    assertParseFormatEquals("995-1-2T3:4:5Z", "0995-01-02T03:04:05Z"); // wasn't 0 padded
  }

  private void assertParseFormatEquals(String inputStr, String expectedStr) {
    if (expectedStr == null) {
      expectedStr = inputStr;
    }
    Date inputDate = DateMathParser.parseMath(null, inputStr);
    String resultStr = inputDate.toInstant().toString();
    assertEquals("d:" + inputDate.getTime(), expectedStr, resultStr);
  }
}

