1 /*
   2  * Copyright (c) 2014, 2020, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.  Oracle designates this
   8  * particular file as subject to the "Classpath" exception as provided
   9  * by Oracle in the LICENSE file that accompanied this code.
  10  *
  11  * This code is distributed in the hope that it will be useful, but WITHOUT
  12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  14  * version 2 for more details (a copy is included in the LICENSE file that
  15  * accompanied this code).
  16  *
  17  * You should have received a copy of the GNU General Public License version
  18  * 2 along with this work; if not, write to the Free Software Foundation,
  19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  20  *
  21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  22  * or visit www.oracle.com if you need additional information or have any
  23  * questions.
  24  */
  25 
  26 
  27 package org.openjdk.buildtools.tzdb;
  28 
  29 import java.io.IOException;
  30 import java.nio.charset.StandardCharsets;
  31 import java.nio.file.Files;
  32 import java.nio.file.Path;
  33 import java.nio.file.Paths;
  34 import java.util.*;
  35 import java.util.Map.Entry;
  36 import java.util.concurrent.ConcurrentSkipListMap;
  37 import java.time.*;
  38 import java.time.Year;
  39 import java.time.chrono.IsoChronology;
  40 import java.time.temporal.TemporalAdjusters;
  41 import org.openjdk.buildtools.tzdb.ZoneOffsetTransitionRule.TimeDefinition;
  42 import java.time.zone.ZoneRulesException;
  43 
  44 /**
  45  * Compile and build time-zone rules from IANA timezone data
  46  *
  47  * @author Xueming Shen
  48  * @author Stephen Colebourne
  49  * @author Michael Nascimento Santos
  50  *
  51  * @since   9
  52  */
  53 
  54 class TzdbZoneRulesProvider {
  55 
  56     /**
  57      * Creates an instance.
  58      *
  59      * @throws ZoneRulesException if unable to load
  60      */
  61     public TzdbZoneRulesProvider(List<Path> files) {
  62         try {
  63              load(files);
  64         } catch (Exception ex) {
  65             throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex);
  66         }
  67     }
  68 
  69     public Set<String> getZoneIds() {
  70         return new TreeSet<String>(regionIds);
  71     }
  72 
  73     public Map<String, String> getAliasMap() {
  74         return links;
  75     }
  76 
  77     public ZoneRules getZoneRules(String zoneId) {
  78         Object obj = zones.get(zoneId);
  79         if (obj == null) {
  80             String zoneId0 = zoneId;
  81             if (links.containsKey(zoneId)) {
  82                 zoneId = links.get(zoneId);
  83                 obj = zones.get(zoneId);
  84             }
  85             if (obj == null) {
  86                 // Timezone link can be located in 'backward' file and it
  87                 // can refer to another link, so we need to check for
  88                 // link one more time, before throwing an exception
  89                 String zoneIdBack = zoneId;
  90                 if (links.containsKey(zoneId)) {
  91                     zoneId = links.get(zoneId);
  92                     obj = zones.get(zoneId);
  93                 }
  94                 if (obj == null) {
  95                     throw new ZoneRulesException("Unknown time-zone ID: " + zoneIdBack);
  96                 }
  97             }
  98         }
  99         if (obj instanceof ZoneRules) {
 100             return (ZoneRules)obj;
 101         }
 102         try {
 103             @SuppressWarnings("unchecked")
 104             ZoneRules zrules = buildRules(zoneId, (List<ZoneLine>)obj);
 105             zones.put(zoneId, zrules);
 106             return zrules;
 107         } catch (Exception ex) {
 108             throw new ZoneRulesException(
 109                 "Invalid binary time-zone data: TZDB:" + zoneId, ex);
 110         }
 111     }
 112 
 113     //////////////////////////////////////////////////////////////////////
 114 
 115     /**
 116      * All the regions that are available.
 117      */
 118     private List<String> regionIds = new ArrayList<>(600);
 119 
 120     /**
 121      * Zone region to rules mapping
 122      */
 123     private final Map<String, Object> zones = new ConcurrentSkipListMap<>();
 124 
 125     /**
 126      * compatibility list
 127      */
 128     private static Set<String> excludedZones;
 129     static {
 130         // (1) exclude EST, HST and MST. They are supported
 131         //     via the short-id mapping
 132         // (2) remove UTC and GMT
 133         // (3) remove ROC, which is not supported in j.u.tz
 134         excludedZones = new TreeSet<>();
 135         excludedZones.add("EST");
 136         excludedZones.add("HST");
 137         excludedZones.add("MST");
 138         excludedZones.add("GMT+0");
 139         excludedZones.add("GMT-0");
 140         excludedZones.add("ROC");
 141     }
 142 
 143     private Map<String, String> links = new TreeMap<>();
 144     private Map<String, List<RuleLine>> rules = new TreeMap<>();
 145 
 146     private void load(List<Path> files) throws IOException {
 147 
 148         for (Path file : files) {
 149             List<ZoneLine> openZone = null;
 150             try {
 151                 for (String line : Files.readAllLines(file, StandardCharsets.ISO_8859_1)) {
 152                     if (line.length() == 0 || line.charAt(0) == '#') {
 153                         continue;
 154                     }
 155                     //StringIterator itr = new StringIterator(line);
 156                     String[] tokens = split(line);
 157                     if (openZone != null &&               // continuing zone line
 158                         Character.isWhitespace(line.charAt(0)) &&
 159                         tokens.length > 0) {
 160                         ZoneLine zLine = new ZoneLine();
 161                         openZone.add(zLine);
 162                         if (zLine.parse(tokens, 0)) {
 163                             openZone = null;
 164                         }
 165                         continue;
 166                     }
 167                     if (line.startsWith("Zone")) {        // parse Zone line
 168                         String name = tokens[1];
 169                         if (excludedZones.contains(name)){
 170                             continue;
 171                         }
 172                         if (zones.containsKey(name)) {
 173                             throw new IllegalArgumentException(
 174                                 "Duplicated zone name in file: " + name +
 175                                 ", line: [" + line + "]");
 176                         }
 177                         openZone = new ArrayList<>(10);
 178                         zones.put(name, openZone);
 179                         regionIds.add(name);
 180                         ZoneLine zLine = new ZoneLine();
 181                         openZone.add(zLine);
 182                         if (zLine.parse(tokens, 2)) {
 183                             openZone = null;
 184                         }
 185                     } else if (line.startsWith("Rule")) { // parse Rule line
 186                         String name = tokens[1];
 187                         if (!rules.containsKey(name)) {
 188                             rules.put(name, new ArrayList<RuleLine>(10));
 189                         }
 190                         rules.get(name).add(new RuleLine().parse(tokens));
 191                     } else if (line.startsWith("Link")) { // parse link line
 192                         if (tokens.length >= 3) {
 193                             String realId = tokens[1];
 194                             String aliasId = tokens[2];
 195                             if (excludedZones.contains(aliasId)){
 196                                 continue;
 197                             }
 198                             links.put(aliasId, realId);
 199                             regionIds.add(aliasId);
 200                         } else {
 201                             throw new IllegalArgumentException(
 202                                 "Invalid Link line in file" +
 203                                 file + ", line: [" + line + "]");
 204                         }
 205                     } else {
 206                         // skip unknown line
 207                     }
 208                 }
 209 
 210             } catch (Exception ex) {
 211                 throw new RuntimeException("Failed while processing file [" + file +
 212                                            "]", ex);
 213             }
 214         }
 215     }
 216 
 217     private String[] split(String str) {
 218         int off = 0;
 219         int end = str.length();
 220         ArrayList<String> list = new ArrayList<>(10);
 221         while (off < end) {
 222             char c = str.charAt(off);
 223             if (c == '\t' || c == ' ') {
 224                 off++;
 225                 continue;
 226             }
 227             if (c == '#') {    // comment
 228                 break;
 229             }
 230             int start = off;
 231             while (off < end) {
 232                 c = str.charAt(off);
 233                 if (c == ' ' || c == '\t') {
 234                     break;
 235                 }
 236                 off++;
 237             }
 238             if (start != off) {
 239                 list.add(str.substring(start, off));
 240             }
 241         }
 242         return list.toArray(new String[list.size()]);
 243     }
 244 
 245     /**
 246      * Class representing a month-day-time in the TZDB file.
 247      */
 248     private static abstract class MonthDayTime {
 249         /** The month of the cutover. */
 250         Month month = Month.JANUARY;
 251 
 252         /** The day-of-month of the cutover. */
 253         int dayOfMonth = 1;
 254 
 255         /** Whether to adjust forwards. */
 256         boolean adjustForwards = true;
 257 
 258         /** The day-of-week of the cutover. */
 259         DayOfWeek dayOfWeek;
 260 
 261         /** The time of the cutover, in second of day */
 262         int secsOfDay = 0;
 263 
 264         /** Whether this is midnight end of day. */
 265         boolean endOfDay;
 266 
 267         /** The time definition of the cutover. */
 268         TimeDefinition timeDefinition = TimeDefinition.WALL;
 269 
 270         void adjustToForwards(int year) {
 271             if (adjustForwards == false && dayOfMonth > 0) {
 272                 // weekDay<=monthDay case, don't have it in tzdb data for now
 273                 LocalDate adjustedDate = LocalDate.of(year, month, dayOfMonth).minusDays(6);
 274                 dayOfMonth = adjustedDate.getDayOfMonth();
 275                 month = adjustedDate.getMonth();
 276                 adjustForwards = true;
 277             }
 278         }
 279 
 280         LocalDateTime toDateTime(int year) {
 281             LocalDate date;
 282             if (dayOfMonth < 0) {
 283                 int monthLen = month.length(IsoChronology.INSTANCE.isLeapYear(year));
 284                 date = LocalDate.of(year, month, monthLen + 1 + dayOfMonth);
 285                 if (dayOfWeek != null) {
 286                     date = date.with(TemporalAdjusters.previousOrSame(dayOfWeek));
 287                 }
 288             } else {
 289                 date = LocalDate.of(year, month, dayOfMonth);
 290                 if (dayOfWeek != null) {
 291                     date = date.with(TemporalAdjusters.nextOrSame(dayOfWeek));
 292                 }
 293             }
 294             if (endOfDay) {
 295                 date = date.plusDays(1);
 296             }
 297             return LocalDateTime.of(date, LocalTime.ofSecondOfDay(secsOfDay));
 298         }
 299 
 300         /**
 301          * Parses the MonthDaytime segment of a tzdb line.
 302          */
 303         private void parse(String[] tokens, int off) {
 304             month = parseMonth(tokens[off++]);
 305             if (off < tokens.length) {
 306                 String dayRule = tokens[off++];
 307                 if (dayRule.startsWith("last")) {
 308                     dayOfMonth = -1;
 309                     dayOfWeek = parseDayOfWeek(dayRule.substring(4));
 310                     adjustForwards = false;
 311                 } else {
 312                     int index = dayRule.indexOf(">=");
 313                     if (index > 0) {
 314                         dayOfWeek = parseDayOfWeek(dayRule.substring(0, index));
 315                         dayRule = dayRule.substring(index + 2);
 316                     } else {
 317                         index = dayRule.indexOf("<=");
 318                         if (index > 0) {
 319                             dayOfWeek = parseDayOfWeek(dayRule.substring(0, index));
 320                             adjustForwards = false;
 321                             dayRule = dayRule.substring(index + 2);
 322                         }
 323                     }
 324                     dayOfMonth = Integer.parseInt(dayRule);
 325                     if (dayOfMonth < -28 || dayOfMonth > 31 || dayOfMonth == 0) {
 326                        throw new IllegalArgumentException(
 327                           "Day of month indicator must be between -28 and 31 inclusive excluding zero");
 328                     }
 329                 }
 330                 if (off < tokens.length) {
 331                     String timeStr = tokens[off++];
 332                     secsOfDay = parseSecs(timeStr);
 333                     if (secsOfDay == 86400) {
 334                         // time must be midnight when end of day flag is true
 335                         endOfDay = true;
 336                         secsOfDay = 0;
 337                     } else if (secsOfDay < 0 || secsOfDay > 86400) {
 338                         // beyond 0:00-24:00 range. Adjust the cutover date.
 339                         int beyondDays = secsOfDay / 86400;
 340                         secsOfDay %= 86400;
 341                         if (secsOfDay < 0) {
 342                             secsOfDay = 86400 + secsOfDay;
 343                             beyondDays -= 1;
 344                         }
 345                         LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(beyondDays);  // leap-year
 346                         month = date.getMonth();
 347                         dayOfMonth = date.getDayOfMonth();
 348                         if (dayOfWeek != null) {
 349                             dayOfWeek = dayOfWeek.plus(beyondDays);
 350                         }
 351                     }
 352                     timeDefinition = parseTimeDefinition(timeStr.charAt(timeStr.length() - 1));
 353                 }
 354             }
 355         }
 356 
 357         int parseYear(String year, int defaultYear) {
 358             switch (year.toLowerCase()) {
 359             case "min":  return 1900;
 360             case "max":  return Year.MAX_VALUE;
 361             case "only": return defaultYear;
 362             }
 363             return Integer.parseInt(year);
 364         }
 365 
 366         Month parseMonth(String mon) {
 367             switch (mon) {
 368             case "Jan": return Month.JANUARY;
 369             case "Feb": return Month.FEBRUARY;
 370             case "Mar": return Month.MARCH;
 371             case "Apr": return Month.APRIL;
 372             case "May": return Month.MAY;
 373             case "Jun": return Month.JUNE;
 374             case "Jul": return Month.JULY;
 375             case "Aug": return Month.AUGUST;
 376             case "Sep": return Month.SEPTEMBER;
 377             case "Oct": return Month.OCTOBER;
 378             case "Nov": return Month.NOVEMBER;
 379             case "Dec": return Month.DECEMBER;
 380             }
 381             throw new IllegalArgumentException("Unknown month: " + mon);
 382         }
 383 
 384         DayOfWeek parseDayOfWeek(String dow) {
 385             switch (dow) {
 386             case "Mon": return DayOfWeek.MONDAY;
 387             case "Tue": return DayOfWeek.TUESDAY;
 388             case "Wed": return DayOfWeek.WEDNESDAY;
 389             case "Thu": return DayOfWeek.THURSDAY;
 390             case "Fri": return DayOfWeek.FRIDAY;
 391             case "Sat": return DayOfWeek.SATURDAY;
 392             case "Sun": return DayOfWeek.SUNDAY;
 393             }
 394             throw new IllegalArgumentException("Unknown day-of-week: " + dow);
 395         }
 396 
 397         String parseOptional(String str) {
 398             return str.equals("-") ? null : str;
 399         }
 400 
 401         static final boolean isDigit(char c) {
 402             return c >= '0' && c <= '9';
 403         }
 404 
 405         private int parseSecs(String time) {
 406             if (time.equals("-")) {
 407                 return 0;
 408             }
 409             // faster hack
 410             int secs = 0;
 411             int sign = 1;
 412             int off = 0;
 413             int len = time.length();
 414             if (off < len && time.charAt(off) == '-') {
 415                 sign = -1;
 416                 off++;
 417             }
 418             char c0, c1;
 419             if (off < len && isDigit(c0 = time.charAt(off++))) {
 420                 int hour = c0 - '0';
 421                 if (off < len && isDigit(c1 = time.charAt(off))) {
 422                     hour = hour * 10 + c1 - '0';
 423                     off++;
 424                 }
 425                 secs = hour * 60 * 60;
 426                 if (off < len && time.charAt(off++) == ':') {
 427                     if (off + 1 < len &&
 428                         isDigit(c0 = time.charAt(off++)) &&
 429                         isDigit(c1 = time.charAt(off++))) {
 430                         // minutes
 431                         secs += ((c0 - '0') * 10 + c1 - '0') * 60;
 432                         if (off < len && time.charAt(off++) == ':') {
 433                             if (off + 1 < len &&
 434                                 isDigit(c0 = time.charAt(off++)) &&
 435                                 isDigit(c1 = time.charAt(off++))) {
 436                                 // seconds
 437                                 secs += ((c0 - '0') * 10 + c1 - '0');
 438                             }
 439                         }
 440                     }
 441 
 442                 }
 443                 return secs * sign;
 444             }
 445             throw new IllegalArgumentException("[" + time + "]");
 446         }
 447 
 448         int parseOffset(String str) {
 449             int secs = parseSecs(str);
 450             if (Math.abs(secs) > 18 * 60 * 60) {
 451                 throw new IllegalArgumentException(
 452                     "Zone offset not in valid range: -18:00 to +18:00");
 453             }
 454             return secs;
 455         }
 456 
 457         int parsePeriod(String str) {
 458             return parseSecs(str);
 459         }
 460 
 461         TimeDefinition parseTimeDefinition(char c) {
 462             switch (c) {
 463             case 's':
 464             case 'S':
 465                 // standard time
 466                 return TimeDefinition.STANDARD;
 467             case 'u':
 468             case 'U':
 469             case 'g':
 470             case 'G':
 471             case 'z':
 472             case 'Z':
 473                 // UTC
 474                 return TimeDefinition.UTC;
 475             case 'w':
 476             case 'W':
 477             default:
 478                 // wall time
 479                 return TimeDefinition.WALL;
 480             }
 481         }
 482     }
 483 
 484     /**
 485      * Class representing a rule line in the TZDB file.
 486      */
 487     private static class RuleLine extends MonthDayTime {
 488         /** The start year. */
 489         int startYear;
 490 
 491         /** The end year. */
 492         int endYear;
 493 
 494         /** The amount of savings, in seconds. */
 495         int savingsAmount;
 496 
 497         /** The text name of the zone. */
 498         String text;
 499 
 500         /**
 501          * Converts this to a transition rule.
 502          *
 503          * @param standardOffset  the active standard offset, not null
 504          * @param savingsBeforeSecs  the active savings before the transition in seconds
 505          * @param negativeSavings minimum savings in the rule, usually zero, but negative if negative DST is
 506          *                   in effect.
 507          * @return the transition, not null
 508         */
 509         ZoneOffsetTransitionRule toTransitionRule(ZoneOffset stdOffset, int savingsBefore, int negativeSavings) {
 510             // rule shared by different zones, so don't change it
 511             Month month = this.month;
 512             int dayOfMonth = this.dayOfMonth;
 513             DayOfWeek dayOfWeek = this.dayOfWeek;
 514             boolean endOfDay = this.endOfDay;
 515 
 516             // optimize stored format
 517             if (dayOfMonth < 0) {
 518                 if (month != Month.FEBRUARY) {    // not Month.FEBRUARY
 519                     dayOfMonth = month.maxLength() - 6;
 520                 }
 521             }
 522             if (endOfDay && dayOfMonth > 0 &&
 523                 (dayOfMonth == 28 && month == Month.FEBRUARY) == false) {
 524                 LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(1);  // leap-year
 525                 month = date.getMonth();
 526                 dayOfMonth = date.getDayOfMonth();
 527                 if (dayOfWeek != null) {
 528                     dayOfWeek = dayOfWeek.plus(1);
 529                 }
 530                 endOfDay = false;
 531             }
 532 
 533             // build rule
 534             return ZoneOffsetTransitionRule.of(
 535                     //month, dayOfMonth, dayOfWeek, time, endOfDay, timeDefinition,
 536                     month, dayOfMonth, dayOfWeek,
 537                     LocalTime.ofSecondOfDay(secsOfDay), endOfDay, timeDefinition,
 538                     stdOffset,
 539                     ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsBefore),
 540                     ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsAmount - negativeSavings));
 541         }
 542 
 543         RuleLine parse(String[] tokens) {
 544             startYear = parseYear(tokens[2], 0);
 545             endYear = parseYear(tokens[3], startYear);
 546             if (startYear > endYear) {
 547                 throw new IllegalArgumentException(
 548                     "Invalid <Rule> line/Year order invalid:" + startYear + " > " + endYear);
 549             }
 550             //parseOptional(s.next());  // type is unused
 551             super.parse(tokens, 5);     // monthdaytime parsing
 552             savingsAmount = parsePeriod(tokens[8]);
 553             //rule.text = parseOptional(s.next());
 554             return this;
 555         }
 556     }
 557 
 558     /**
 559      * Class representing a linked set of zone lines in the TZDB file.
 560      */
 561     private static class ZoneLine extends MonthDayTime {
 562         /** The standard offset. */
 563         int stdOffsetSecs;
 564 
 565         /** The fixed savings amount. */
 566         int fixedSavingsSecs = 0;
 567 
 568         /** The savings rule. */
 569         String savingsRule;
 570 
 571         /** The text name of the zone. */
 572         String text;
 573 
 574         /** The cutover year */
 575         int year = Year.MAX_VALUE;
 576 
 577         /** The cutover date time */
 578         LocalDateTime ldt;
 579 
 580         /** The cutover date/time in epoch seconds/UTC */
 581         long ldtSecs = Long.MIN_VALUE;
 582 
 583         LocalDateTime toDateTime() {
 584             if (ldt == null) {
 585                 ldt = toDateTime(year);
 586             }
 587             return ldt;
 588         }
 589 
 590         /**
 591          * Creates the date-time epoch second in the wall offset for the local
 592          * date-time at the end of the window.
 593          *
 594          * @param savingsSecs  the amount of savings in use in seconds
 595          * @return the created date-time epoch second in the wall offset, not null
 596          */
 597         long toDateTimeEpochSecond(int savingsSecs) {
 598             if (ldtSecs == Long.MIN_VALUE) {
 599                 ldtSecs = toDateTime().toEpochSecond(ZoneOffset.UTC);
 600             }
 601             switch(timeDefinition) {
 602             case UTC:      return ldtSecs;
 603             case STANDARD: return ldtSecs - stdOffsetSecs;
 604             default:       return ldtSecs - (stdOffsetSecs + savingsSecs); // WALL
 605             }
 606         }
 607 
 608         boolean parse(String[] tokens, int off) {
 609             stdOffsetSecs = parseOffset(tokens[off++]);
 610             savingsRule = parseOptional(tokens[off++]);
 611             if (savingsRule != null && savingsRule.length() > 0 &&
 612                 (savingsRule.charAt(0) == '-' || isDigit(savingsRule.charAt(0)))) {
 613                 try {
 614                     fixedSavingsSecs = parsePeriod(savingsRule);
 615                     savingsRule = null;
 616                 } catch (Exception ex) {
 617                     fixedSavingsSecs = 0;
 618                 }
 619             }
 620             text = tokens[off++];
 621             if (off < tokens.length) {
 622                 year = Integer.parseInt(tokens[off++]);
 623                 if (off < tokens.length) {
 624                     super.parse(tokens, off);  // MonthDayTime
 625                 }
 626                 return false;
 627             } else {
 628                 return true;
 629             }
 630         }
 631     }
 632 
 633     /**
 634      * Class representing a rule line in the TZDB file for a particular year.
 635      */
 636     private static class TransRule implements Comparable<TransRule>
 637     {
 638         private int year;
 639         private RuleLine rule;
 640 
 641         /** The trans date/time */
 642         private LocalDateTime ldt;
 643 
 644         /** The trans date/time in epoch seconds (assume UTC) */
 645         long ldtSecs;
 646 
 647         TransRule(int year, RuleLine rule) {
 648             this.year = year;
 649             this.rule = rule;
 650             this.ldt = rule.toDateTime(year);
 651             this.ldtSecs = ldt.toEpochSecond(ZoneOffset.UTC);
 652         }
 653 
 654         ZoneOffsetTransition toTransition(ZoneOffset standardOffset, int savingsBeforeSecs, int negativeSavings) {
 655             // copy of code in ZoneOffsetTransitionRule to avoid infinite loop
 656             ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds(
 657                 standardOffset.getTotalSeconds() + savingsBeforeSecs);
 658             ZoneOffset offsetAfter = ZoneOffset.ofTotalSeconds(
 659                 standardOffset.getTotalSeconds() + rule.savingsAmount - negativeSavings);
 660             LocalDateTime dt = rule.timeDefinition
 661                                    .createDateTime(ldt, standardOffset, wallOffset);
 662             return ZoneOffsetTransition.of(dt, wallOffset, offsetAfter);
 663         }
 664 
 665         long toEpochSecond(ZoneOffset stdOffset, int savingsBeforeSecs) {
 666             switch(rule.timeDefinition) {
 667             case UTC:      return ldtSecs;
 668             case STANDARD: return ldtSecs - stdOffset.getTotalSeconds();
 669             default:       return ldtSecs - (stdOffset.getTotalSeconds() + savingsBeforeSecs); // WALL
 670             }
 671         }
 672 
 673         /**
 674          * Tests if this a real transition with the active savings in seconds
 675          *
 676          * @param savingsBefore the active savings in seconds
 677          * @param negativeSavings minimum savings in the rule, usually zero, but negative if negative DST is
 678          *                   in effect.
 679          * @return true, if savings changes
 680          */
 681         boolean isTransition(int savingsBefore, int negativeSavings) {
 682             return rule.savingsAmount - negativeSavings != savingsBefore;
 683         }
 684 
 685         public int compareTo(TransRule other) {
 686             return (ldtSecs < other.ldtSecs)? -1 : ((ldtSecs == other.ldtSecs) ? 0 : 1);
 687         }
 688     }
 689 
 690     private ZoneRules buildRules(String zoneId, List<ZoneLine> zones) {
 691         if (zones.isEmpty()) {
 692             throw new IllegalStateException("No available zone window");
 693         }
 694         final List<ZoneOffsetTransition> standardTransitionList = new ArrayList<>(4);
 695         final List<ZoneOffsetTransition> transitionList = new ArrayList<>(256);
 696         final List<ZoneOffsetTransitionRule> lastTransitionRuleList = new ArrayList<>(2);
 697 
 698         final ZoneLine zone0 = zones.get(0);
 699         // initialize the standard offset, wallOffset and savings for loop
 700 
 701         //ZoneOffset stdOffset = zone0.standardOffset;
 702         ZoneOffset stdOffset = ZoneOffset.ofTotalSeconds(zone0.stdOffsetSecs);
 703 
 704         int savings = zone0.fixedSavingsSecs;
 705         ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings);
 706 
 707         // start ldt of each zone window
 708         LocalDateTime zoneStart = LocalDateTime.MIN;
 709 
 710         // first standard offset
 711         ZoneOffset firstStdOffset = stdOffset;
 712         // first wall offset
 713         ZoneOffset firstWallOffset = wallOffset;
 714 
 715         for (ZoneLine zone : zones) {
 716             // Adjust stdOffset, if negative DST is observed. It should be either
 717             // fixed amount, or expressed in the named Rules.
 718             int negativeSavings = Math.min(zone.fixedSavingsSecs, findNegativeSavings(zoneStart, zone));
 719             if (negativeSavings < 0) {
 720                 zone.stdOffsetSecs += negativeSavings;
 721                 if (zone.fixedSavingsSecs < 0) {
 722                     zone.fixedSavingsSecs = 0;
 723                 }
 724             }
 725 
 726             // check if standard offset changed, update it if yes
 727             ZoneOffset stdOffsetPrev = stdOffset;  // for effectiveSavings check
 728             if (zone.stdOffsetSecs != stdOffset.getTotalSeconds()) {
 729                 ZoneOffset stdOffsetNew = ZoneOffset.ofTotalSeconds(zone.stdOffsetSecs);
 730                 standardTransitionList.add(
 731                     ZoneOffsetTransition.of(
 732                         LocalDateTime.ofEpochSecond(zoneStart.toEpochSecond(wallOffset),
 733                                                     0,
 734                                                     stdOffset),
 735                         stdOffset,
 736                         stdOffsetNew));
 737                 stdOffset = stdOffsetNew;
 738             }
 739 
 740             LocalDateTime zoneEnd;
 741             if (zone.year == Year.MAX_VALUE) {
 742                 zoneEnd = LocalDateTime.MAX;
 743             } else {
 744                 zoneEnd = zone.toDateTime();
 745             }
 746             if (zoneEnd.compareTo(zoneStart) < 0) {
 747                 throw new IllegalStateException("Windows must be in date-time order: " +
 748                         zoneEnd + " < " + zoneStart);
 749             }
 750             // calculate effective savings at the start of the window
 751             List<TransRule> trules = null;
 752             List<TransRule> lastRules = null;
 753 
 754             int effectiveSavings = zone.fixedSavingsSecs;
 755             if (zone.savingsRule != null) {
 756                 List<RuleLine> tzdbRules = rules.get(zone.savingsRule);
 757                 if (tzdbRules == null) {
 758                    throw new IllegalArgumentException("<Rule> not found: " +
 759                                                        zone.savingsRule);
 760                 }
 761                 trules = new ArrayList<>(256);
 762                 lastRules = new ArrayList<>(2);
 763                 int lastRulesStartYear = Year.MIN_VALUE;
 764 
 765                 // merge the rules to transitions
 766                 for (RuleLine rule : tzdbRules) {
 767                     if (rule.startYear > zoneEnd.getYear()) {
 768                         // rules will not be used for this zone entry
 769                         continue;
 770                     }
 771                     rule.adjustToForwards(2004);  // irrelevant, treat as leap year
 772 
 773                     int startYear = rule.startYear;
 774                     int endYear = rule.endYear;
 775                     if (zoneEnd.equals(LocalDateTime.MAX)) {
 776                         if (endYear == Year.MAX_VALUE) {
 777                             endYear = startYear;
 778                             lastRules.add(new TransRule(endYear, rule));
 779                         }
 780                         lastRulesStartYear = Math.max(startYear, lastRulesStartYear);
 781                     } else {
 782                         if (endYear == Year.MAX_VALUE) {
 783                             //endYear = zoneEnd.getYear();
 784                             endYear = zone.year;
 785                         }
 786                     }
 787                     int year = startYear;
 788                     while (year <= endYear) {
 789                         trules.add(new TransRule(year, rule));
 790                         year++;
 791                     }
 792                 }
 793 
 794                 // last rules, fill the gap years between different last rules
 795                 if (zoneEnd.equals(LocalDateTime.MAX)) {
 796                     lastRulesStartYear = Math.max(lastRulesStartYear, zoneStart.getYear()) + 1;
 797                     for (TransRule rule : lastRules) {
 798                         if (rule.year <= lastRulesStartYear) {
 799                             int year = rule.year;
 800                             while (year <= lastRulesStartYear) {
 801                                 trules.add(new TransRule(year, rule.rule));
 802                                 year++;
 803                             }
 804                             rule.year = lastRulesStartYear;
 805                             rule.ldt = rule.rule.toDateTime(year);
 806                             rule.ldtSecs = rule.ldt.toEpochSecond(ZoneOffset.UTC);
 807                         }
 808                     }
 809                     Collections.sort(lastRules);
 810                 }
 811                 // sort the merged rules
 812                 Collections.sort(trules);
 813 
 814                 effectiveSavings = -negativeSavings;
 815                 for (TransRule rule : trules) {
 816                     if (rule.toEpochSecond(stdOffsetPrev, savings) >
 817                         zoneStart.toEpochSecond(wallOffset)) {
 818                         // previous savings amount found, which could be the
 819                         // savings amount at the instant that the window starts
 820                         // (hence isAfter)
 821                         break;
 822                     }
 823                     effectiveSavings = rule.rule.savingsAmount - negativeSavings;
 824                 }
 825             }
 826             // check if the start of the window represents a transition
 827             ZoneOffset effectiveWallOffset =
 828                 ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + effectiveSavings);
 829 
 830             if (!wallOffset.equals(effectiveWallOffset)) {
 831                 transitionList.add(ZoneOffsetTransition.of(zoneStart,
 832                                                            wallOffset,
 833                                                            effectiveWallOffset));
 834             }
 835             savings = effectiveSavings;
 836             // apply rules within the window
 837             if (trules != null) {
 838                 long zoneStartEpochSecs = zoneStart.toEpochSecond(wallOffset);
 839                 for (TransRule trule : trules) {
 840                     if (trule.isTransition(savings, negativeSavings)) {
 841                         long epochSecs = trule.toEpochSecond(stdOffset, savings);
 842                         if (epochSecs < zoneStartEpochSecs ||
 843                             epochSecs >= zone.toDateTimeEpochSecond(savings)) {
 844                             continue;
 845                         }
 846                         transitionList.add(trule.toTransition(stdOffset, savings, negativeSavings));
 847                         savings = trule.rule.savingsAmount - negativeSavings;
 848                     }
 849                 }
 850             }
 851             if (lastRules != null) {
 852                 for (TransRule trule : lastRules) {
 853                     lastTransitionRuleList.add(trule.rule.toTransitionRule(stdOffset, savings, negativeSavings));
 854                     savings = trule.rule.savingsAmount - negativeSavings;
 855                 }
 856             }
 857 
 858             // finally we can calculate the true end of the window, passing it to the next window
 859             wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings);
 860             zoneStart = LocalDateTime.ofEpochSecond(zone.toDateTimeEpochSecond(savings),
 861                                                     0,
 862                                                     wallOffset);
 863         }
 864         return new ZoneRules(firstStdOffset,
 865                              firstWallOffset,
 866                              standardTransitionList,
 867                              transitionList,
 868                              lastTransitionRuleList);
 869     }
 870 
 871     /**
 872      * Find the minimum negative savings in named Rules for a Zone. Savings are only
 873      * looked at for the period of the subject Zone.
 874      *
 875      * @param zoneStart start LDT of the zone
 876      * @param zl ZoneLine to look at
 877      */
 878     private int findNegativeSavings(LocalDateTime zoneStart, ZoneLine zl) {
 879         int negativeSavings = 0;
 880         LocalDateTime zoneEnd = zl.toDateTime();
 881 
 882         if (zl.savingsRule != null) {
 883             List<RuleLine> rlines = rules.get(zl.savingsRule);
 884             if (rlines == null) {
 885                 throw new IllegalArgumentException("<Rule> not found: " +
 886                         zl.savingsRule);
 887             }
 888 
 889             negativeSavings = Math.min(0, rlines.stream()
 890                     .filter(l -> windowOverlap(l, zoneStart.getYear(), zoneEnd.getYear()))
 891                     .map(l -> l.savingsAmount)
 892                     .min(Comparator.naturalOrder())
 893                     .orElse(0));
 894         }
 895 
 896         return negativeSavings;
 897     }
 898 
 899     private boolean windowOverlap(RuleLine ruleLine, int zoneStartYear, int zoneEndYear) {
 900         boolean overlap = zoneStartYear <= ruleLine.startYear && zoneEndYear >= ruleLine.startYear ||
 901                           zoneStartYear <= ruleLine.endYear && zoneEndYear >= ruleLine.endYear;
 902 
 903         return overlap;
 904     }
 905 }