public static Timestamp convertTimestampToZone(Timestamp ts, ZoneId fromZone, ZoneId toZone,
      boolean legacyConversion) {
    if (legacyConversion) {
      try {
        DateFormat formatter = getLegacyDateFormatter();
        formatter.setTimeZone(TimeZone.getTimeZone(fromZone));
        java.util.Date date = formatter.parse(ts.format(TIMESTAMP_FORMATTER));
        // Set the formatter to use a different timezone
        formatter.setTimeZone(TimeZone.getTimeZone(toZone));
        Timestamp result;
        try {
          result = Timestamp.valueOf(formatter.format(date));
        } catch (IllegalArgumentException ex) {
          if (ex.getCause().getMessage().contains(NOT_LEAP_YEAR)) {
            int leapYearDateAdjustment = ts.getMonth() == Month.MARCH.getValue() ? 2 : -2;
            result = Timestamp.valueOf(formatter.format(DateUtils.addDays(date, leapYearDateAdjustment)))
                .minusDays(leapYearDateAdjustment);
          } else {
            throw ex;
          }
        }
        result.setNanos(ts.getNanos());
        return result;
      } catch (ParseException e) {
        throw new RuntimeException(e);
      }
    }

    // get nanos since [epoch at fromZone]
    Instant instant = convert(ts, fromZone).getZonedDateTime().toInstant();
    // get [local time at toZone]
    LocalDateTime localDateTimeAtToZone = LocalDateTime.ofInstant(instant, toZone);
    // get nanos between [epoch at toZone] and [local time at toZone]
    return Timestamp.ofEpochSecond(localDateTimeAtToZone.toEpochSecond(ZoneOffset.UTC),
        localDateTimeAtToZone.getNano());
  }
Timestamps are technically time zone agnostic, and this method sort of cheats its logic.
Timestamps are supposed to represent nanos since [UTC epoch]. Here,
the input timestamp represents nanoseconds since [epoch at fromZone], and
we return a Timestamp representing nanoseconds since [epoch at toZone].
common/src/java/org/apache/hadoop/hive/common/type/TimestampTZUtil.java