/*
 * Copyright (c) 2014, 2024, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code 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 General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */


package build.tools.tzdb;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentSkipListMap;
import java.time.*;
import java.time.Year;
import java.time.chrono.IsoChronology;
import java.time.temporal.TemporalAdjusters;
import build.tools.tzdb.ZoneOffsetTransitionRule.TimeDefinition;
import java.time.zone.ZoneRulesException;

/**
 * Compile and build time-zone rules from IANA timezone data
 *
 * @author Xueming Shen
 * @author Stephen Colebourne
 * @author Michael Nascimento Santos
 *
 * @since   9
 */

class TzdbZoneRulesProvider {

    /**
     * Creates an instance.
     *
     * @throws ZoneRulesException if unable to load
     */
    public TzdbZoneRulesProvider(List<Path> files) {
        try {
             load(files);
        } catch (Exception ex) {
            throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex);
        }
    }

    public Set<String> getZoneIds() {
        return new TreeSet(regionIds);
    }

    public Map<String, String> getAliasMap() {
        return links;
    }

    public ZoneRules getZoneRules(String zoneId) {
        Object obj = zones.get(zoneId);
        if (obj == null) {
            String zoneId0 = zoneId;
            if (links.containsKey(zoneId)) {
                zoneId = links.get(zoneId);
                obj = zones.get(zoneId);
            }
            if (obj == null) {
                // Timezone link can be located in 'backward' file and it
                // can refer to another link, so we need to check for
                // link one more time, before throwing an exception
                String zoneIdBack = zoneId;
                if (links.containsKey(zoneId)) {
                    zoneId = links.get(zoneId);
                    obj = zones.get(zoneId);
                }
                if (obj == null) {
                    throw new ZoneRulesException("Unknown time-zone ID: " + zoneIdBack);
                }
            }
        }
        if (obj instanceof ZoneRules) {
            return (ZoneRules)obj;
        }
        try {
            ZoneRules zrules = buildRules(zoneId, (List<ZoneLine>)obj);
            zones.put(zoneId, zrules);
            return zrules;
        } catch (Exception ex) {
            throw new ZoneRulesException(
                "Invalid binary time-zone data: TZDB:" + zoneId, ex);
        }
    }

    //////////////////////////////////////////////////////////////////////

    /**
     * All the regions that are available.
     */
    private List<String> regionIds = new ArrayList<>(600);

    /**
     * Zone region to rules mapping
     */
    private final Map<String, Object> zones = new ConcurrentSkipListMap<>();

    /**
     * compatibility list
     */
    private static Set<String> excludedZones;
    static {
        // (1) exclude EST, HST and MST. They are supported
        //     via the short-id mapping
        // (2) remove UTC and GMT
        // (3) remove ROC, which is not supported in j.u.tz
        excludedZones = new TreeSet<>();
        excludedZones.add("EST");
        excludedZones.add("HST");
        excludedZones.add("MST");
        excludedZones.add("GMT+0");
        excludedZones.add("GMT-0");
        excludedZones.add("ROC");
    }

    private Map<String, String> links = new TreeMap<>();
    private Map<String, List<RuleLine>> rules = new TreeMap<>();

    private void load(List<Path> files) throws IOException {

        for (Path file : files) {
            List<ZoneLine> openZone = null;
            try {
                for (String line : Files.readAllLines(file, StandardCharsets.ISO_8859_1)) {
                    if (line.length() == 0 || line.charAt(0) == '#') {
                        continue;
                    }
                    //StringIterator itr = new StringIterator(line);
                    String[] tokens = split(line);
                    if (openZone != null &&               // continuing zone line
                        Character.isWhitespace(line.charAt(0)) &&
                        tokens.length > 0) {
                        ZoneLine zLine = new ZoneLine();
                        openZone.add(zLine);
                        if (zLine.parse(tokens, 0)) {
                            openZone = null;
                        }
                        continue;
                    }
                    int token0len = tokens.length > 0 ? tokens[0].length() : line.length();
                    if (line.regionMatches(true, 0, "Zone", 0, token0len)) {        // parse Zone line
                        String name = tokens[1];
                        if (excludedZones.contains(name)){
                            continue;
                        }
                        if (zones.containsKey(name)) {
                            throw new IllegalArgumentException(
                                "Duplicated zone name in file: " + name +
                                ", line: [" + line + "]");
                        }
                        openZone = new ArrayList<>(10);
                        zones.put(name, openZone);
                        regionIds.add(name);
                        ZoneLine zLine = new ZoneLine();
                        openZone.add(zLine);
                        if (zLine.parse(tokens, 2)) {
                            openZone = null;
                        }
                    } else if (line.regionMatches(true, 0, "Rule", 0, token0len)) { // parse Rule line
                        String name = tokens[1];
                        if (!rules.containsKey(name)) {
                            rules.put(name, new ArrayList<RuleLine>(10));
                        }
                        rules.get(name).add(new RuleLine().parse(tokens));
                    } else if (line.regionMatches(true, 0, "Link", 0, token0len)) { // parse link line
                        if (tokens.length >= 3) {
                            String realId = tokens[1];
                            String aliasId = tokens[2];
                            if (excludedZones.contains(aliasId)){
                                continue;
                            }
                            links.put(aliasId, realId);
                            regionIds.add(aliasId);
                        } else {
                            throw new IllegalArgumentException(
                                "Invalid Link line in file" +
                                file + ", line: [" + line + "]");
                        }
                    } else {
                        // skip unknown line
                    }
                }

            } catch (Exception ex) {
                throw new RuntimeException("Failed while processing file [" + file +
                                           "]", ex);
            }
        }
    }

    private String[] split(String str) {
        int off = 0;
        int end = str.length();
        ArrayList<String> list = new ArrayList<>(10);
        while (off < end) {
            char c = str.charAt(off);
            if (c == '\t' || c == ' ') {
                off++;
                continue;
            }
            if (c == '#') {    // comment
                break;
            }
            int start = off;
            while (off < end) {
                c = str.charAt(off);
                if (c == ' ' || c == '\t') {
                    break;
                }
                off++;
            }
            if (start != off) {
                list.add(str.substring(start, off));
            }
        }
        return list.toArray(new String[list.size()]);
    }

    /**
     * Class representing a month-day-time in the TZDB file.
     */
    private static abstract class MonthDayTime {
        /** The month of the cutover. */
        Month month = Month.JANUARY;

        /** The day-of-month of the cutover. */
        int dayOfMonth = 1;

        /** Whether to adjust forwards. */
        boolean adjustForwards = true;

        /** The day-of-week of the cutover. */
        DayOfWeek dayOfWeek;

        /** The time of the cutover, in second of day */
        int secsOfDay = 0;

        /** Whether this is midnight end of day. */
        boolean endOfDay;

        /** The time definition of the cutover. */
        TimeDefinition timeDefinition = TimeDefinition.WALL;

        void adjustToForwards(int year) {
            if (adjustForwards == false && dayOfMonth > 0) {
                // weekDay<=monthDay case, don't have it in tzdb data for now
                LocalDate adjustedDate = LocalDate.of(year, month, dayOfMonth).minusDays(6);
                dayOfMonth = adjustedDate.getDayOfMonth();
                month = adjustedDate.getMonth();
                adjustForwards = true;
            }
        }

        LocalDateTime toDateTime(int year) {
            LocalDate date;
            if (dayOfMonth < 0) {
                int monthLen = month.length(IsoChronology.INSTANCE.isLeapYear(year));
                date = LocalDate.of(year, month, monthLen + 1 + dayOfMonth);
                if (dayOfWeek != null) {
                    date = date.with(TemporalAdjusters.previousOrSame(dayOfWeek));
                }
            } else {
                date = LocalDate.of(year, month, dayOfMonth);
                if (dayOfWeek != null) {
                    date = date.with(TemporalAdjusters.nextOrSame(dayOfWeek));
                }
            }
            if (endOfDay) {
                date = date.plusDays(1);
            }
            return LocalDateTime.of(date, LocalTime.ofSecondOfDay(secsOfDay));
        }

        /**
         * Parses the MonthDaytime segment of a tzdb line.
         */
        private void parse(String[] tokens, int off) {
            month = parseMonth(tokens[off++]);
            if (off < tokens.length) {
                String dayRule = tokens[off++];
                if (dayRule.regionMatches(true, 0, "last", 0, 4)) {
                    dayOfMonth = -1;
                    dayOfWeek = parseDayOfWeek(dayRule.substring(4));
                    adjustForwards = false;
                } else {
                    int index = dayRule.indexOf(">=");
                    if (index > 0) {
                        dayOfWeek = parseDayOfWeek(dayRule.substring(0, index));
                        dayRule = dayRule.substring(index + 2);
                    } else {
                        index = dayRule.indexOf("<=");
                        if (index > 0) {
                            dayOfWeek = parseDayOfWeek(dayRule.substring(0, index));
                            adjustForwards = false;
                            dayRule = dayRule.substring(index + 2);
                        }
                    }
                    dayOfMonth = Integer.parseInt(dayRule);
                    if (dayOfMonth < -28 || dayOfMonth > 31 || dayOfMonth == 0) {
                       throw new IllegalArgumentException(
                          "Day of month indicator must be between -28 and 31 inclusive excluding zero");
                    }
                }
                if (off < tokens.length) {
                    String timeStr = tokens[off++];
                    secsOfDay = parseSecs(timeStr);
                    if (secsOfDay == 86400) {
                        // time must be midnight when end of day flag is true
                        endOfDay = true;
                        secsOfDay = 0;
                    } else if (secsOfDay < 0 || secsOfDay > 86400) {
                        // beyond 0:00-24:00 range. Adjust the cutover date.
                        int beyondDays = secsOfDay / 86400;
                        secsOfDay %= 86400;
                        if (secsOfDay < 0) {
                            secsOfDay = 86400 + secsOfDay;
                            beyondDays -= 1;
                        }
                        LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(beyondDays);  // leap-year
                        month = date.getMonth();
                        dayOfMonth = date.getDayOfMonth();
                        if (dayOfWeek != null) {
                            dayOfWeek = dayOfWeek.plus(beyondDays);
                        }
                    }
                    timeDefinition = parseTimeDefinition(timeStr.charAt(timeStr.length() - 1));
                }
            }
        }

        int parseYear(String year, int defaultYear) {
            int len = year.length();

            if (year.regionMatches(true, 0, "minimum", 0, len)) return 1900;
            if (year.regionMatches(true, 0, "maximum", 0, len)) return Year.MAX_VALUE;
            if (year.regionMatches(true, 0, "only", 0, len)) return defaultYear;

            return Integer.parseInt(year);
        }

        Month parseMonth(String mon) {
            int len = mon.length();

            if (mon.regionMatches(true, 0, "January", 0, len)) return Month.JANUARY;
            if (mon.regionMatches(true, 0, "February", 0, len)) return Month.FEBRUARY;
            if (mon.regionMatches(true, 0, "March", 0, len)) return Month.MARCH;
            if (mon.regionMatches(true, 0, "April", 0, len)) return Month.APRIL;
            if (mon.regionMatches(true, 0, "May", 0, len)) return Month.MAY;
            if (mon.regionMatches(true, 0, "June", 0, len)) return Month.JUNE;
            if (mon.regionMatches(true, 0, "July", 0, len)) return Month.JULY;
            if (mon.regionMatches(true, 0, "August", 0, len)) return Month.AUGUST;
            if (mon.regionMatches(true, 0, "September", 0, len)) return Month.SEPTEMBER;
            if (mon.regionMatches(true, 0, "October", 0, len)) return Month.OCTOBER;
            if (mon.regionMatches(true, 0, "November", 0, len)) return Month.NOVEMBER;
            if (mon.regionMatches(true, 0, "December", 0, len)) return Month.DECEMBER;

            throw new IllegalArgumentException("Unknown month: " + mon);
        }

        DayOfWeek parseDayOfWeek(String dow) {
            int len = dow.length();

            if (dow.regionMatches(true, 0, "Monday", 0, len)) return DayOfWeek.MONDAY;
            if (dow.regionMatches(true, 0, "Tuesday", 0, len)) return DayOfWeek.TUESDAY;
            if (dow.regionMatches(true, 0, "Wednesday", 0, len)) return DayOfWeek.WEDNESDAY;
            if (dow.regionMatches(true, 0, "Thursday", 0, len)) return DayOfWeek.THURSDAY;
            if (dow.regionMatches(true, 0, "Friday", 0, len)) return DayOfWeek.FRIDAY;
            if (dow.regionMatches(true, 0, "Saturday", 0, len)) return DayOfWeek.SATURDAY;
            if (dow.regionMatches(true, 0, "Sunday", 0, len)) return DayOfWeek.SUNDAY;

            throw new IllegalArgumentException("Unknown day-of-week: " + dow);
        }

        String parseOptional(String str) {
            return str.equals("-") ? null : str;
        }

        static final boolean isDigit(char c) {
            return c >= '0' && c <= '9';
        }

        private int parseSecs(String time) {
            if (time.equals("-")) {
                return 0;
            }
            // faster hack
            int secs = 0;
            int sign = 1;
            int off = 0;
            int len = time.length();
            if (off < len && time.charAt(off) == '-') {
                sign = -1;
                off++;
            }
            char c0, c1;
            if (off < len && isDigit(c0 = time.charAt(off++))) {
                int hour = c0 - '0';
                if (off < len && isDigit(c1 = time.charAt(off))) {
                    hour = hour * 10 + c1 - '0';
                    off++;
                }
                secs = hour * 60 * 60;
                if (off < len && time.charAt(off++) == ':') {
                    if (off + 1 < len &&
                        isDigit(c0 = time.charAt(off++)) &&
                        isDigit(c1 = time.charAt(off++))) {
                        // minutes
                        secs += ((c0 - '0') * 10 + c1 - '0') * 60;
                        if (off < len && time.charAt(off++) == ':') {
                            if (off + 1 < len &&
                                isDigit(c0 = time.charAt(off++)) &&
                                isDigit(c1 = time.charAt(off++))) {
                                // seconds
                                secs += ((c0 - '0') * 10 + c1 - '0');
                            }
                        }
                    }

                }
                return secs * sign;
            }
            throw new IllegalArgumentException("[" + time + "]");
        }

        int parseOffset(String str) {
            int secs = parseSecs(str);
            if (Math.abs(secs) > 18 * 60 * 60) {
                throw new IllegalArgumentException(
                    "Zone offset not in valid range: -18:00 to +18:00");
            }
            return secs;
        }

        int parsePeriod(String str) {
            return parseSecs(str);
        }

        TimeDefinition parseTimeDefinition(char c) {
            switch (c) {
            case 's':
            case 'S':
                // standard time
                return TimeDefinition.STANDARD;
            case 'u':
            case 'U':
            case 'g':
            case 'G':
            case 'z':
            case 'Z':
                // UTC
                return TimeDefinition.UTC;
            case 'w':
            case 'W':
            default:
                // wall time
                return TimeDefinition.WALL;
            }
        }
    }

    /**
     * Class representing a rule line in the TZDB file.
     */
    private static class RuleLine extends MonthDayTime {
        /** The start year. */
        int startYear;

        /** The end year. */
        int endYear;

        /** The amount of savings, in seconds. */
        int savingsAmount;

        /** The text name of the zone. */
        String text;

        /**
         * Converts this to a transition rule.
         *
         * @param standardOffset  the active standard offset, not null
         * @param savingsBeforeSecs  the active savings before the transition in seconds
         * @param negativeSavings minimum savings in the rule, usually zero, but negative if negative DST is
         *                   in effect.
         * @return the transition, not null
        */
        ZoneOffsetTransitionRule toTransitionRule(ZoneOffset stdOffset, int savingsBefore, int negativeSavings) {
            // rule shared by different zones, so don't change it
            Month month = this.month;
            int dayOfMonth = this.dayOfMonth;
            DayOfWeek dayOfWeek = this.dayOfWeek;
            boolean endOfDay = this.endOfDay;

            // optimize stored format
            if (dayOfMonth < 0) {
                if (month != Month.FEBRUARY) {    // not Month.FEBRUARY
                    dayOfMonth = month.maxLength() - 6;
                }
            }
            if (endOfDay && dayOfMonth > 0 &&
                (dayOfMonth == 28 && month == Month.FEBRUARY) == false) {
                LocalDate date = LocalDate.of(2004, month, dayOfMonth).plusDays(1);  // leap-year
                month = date.getMonth();
                dayOfMonth = date.getDayOfMonth();
                if (dayOfWeek != null) {
                    dayOfWeek = dayOfWeek.plus(1);
                }
                endOfDay = false;
            }

            // build rule
            return ZoneOffsetTransitionRule.of(
                    //month, dayOfMonth, dayOfWeek, time, endOfDay, timeDefinition,
                    month, dayOfMonth, dayOfWeek,
                    LocalTime.ofSecondOfDay(secsOfDay), endOfDay, timeDefinition,
                    stdOffset,
                    ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsBefore),
                    ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savingsAmount - negativeSavings));
        }

        RuleLine parse(String[] tokens) {
            startYear = parseYear(tokens[2], 0);
            endYear = parseYear(tokens[3], startYear);
            if (startYear > endYear) {
                throw new IllegalArgumentException(
                    "Invalid <Rule> line/Year order invalid:" + startYear + " > " + endYear);
            }
            //parseOptional(s.next());  // type is unused
            super.parse(tokens, 5);     // monthdaytime parsing
            savingsAmount = parsePeriod(tokens[8]);
            //rule.text = parseOptional(s.next());
            return this;
        }
    }

    /**
     * Class representing a linked set of zone lines in the TZDB file.
     */
    private static class ZoneLine extends MonthDayTime {
        /** The standard offset. */
        int stdOffsetSecs;

        /** The fixed savings amount. */
        int fixedSavingsSecs = 0;

        /** The savings rule. */
        String savingsRule;

        /** The text name of the zone. */
        String text;

        /** The cutover year */
        int year = Year.MAX_VALUE;

        /** The cutover date time */
        LocalDateTime ldt;

        /** The cutover date/time in epoch seconds/UTC */
        long ldtSecs = Long.MIN_VALUE;

        LocalDateTime toDateTime() {
            if (ldt == null) {
                ldt = toDateTime(year);
            }
            return ldt;
        }

        /**
         * Creates the date-time epoch second in the wall offset for the local
         * date-time at the end of the window.
         *
         * @param savingsSecs  the amount of savings in use in seconds
         * @return the created date-time epoch second in the wall offset, not null
         */
        long toDateTimeEpochSecond(int savingsSecs) {
            if (ldtSecs == Long.MIN_VALUE) {
                ldtSecs = toDateTime().toEpochSecond(ZoneOffset.UTC);
            }
            switch(timeDefinition) {
            case UTC:      return ldtSecs;
            case STANDARD: return ldtSecs - stdOffsetSecs;
            default:       return ldtSecs - (stdOffsetSecs + savingsSecs); // WALL
            }
        }

        boolean parse(String[] tokens, int off) {
            stdOffsetSecs = parseOffset(tokens[off++]);
            savingsRule = parseOptional(tokens[off++]);
            if (savingsRule != null && savingsRule.length() > 0 &&
                (savingsRule.charAt(0) == '-' || isDigit(savingsRule.charAt(0)))) {
                try {
                    fixedSavingsSecs = parsePeriod(savingsRule);
                    savingsRule = null;
                } catch (Exception ex) {
                    fixedSavingsSecs = 0;
                }
            }
            text = tokens[off++];
            if (off < tokens.length) {
                year = Integer.parseInt(tokens[off++]);
                if (off < tokens.length) {
                    super.parse(tokens, off);  // MonthDayTime
                }
                return false;
            } else {
                return true;
            }
        }
    }

    /**
     * Class representing a rule line in the TZDB file for a particular year.
     */
    private static class TransRule implements Comparable<TransRule>
    {
        private int year;
        private RuleLine rule;

        /** The trans date/time */
        private LocalDateTime ldt;

        /** The trans date/time in epoch seconds (assume UTC) */
        long ldtSecs;

        TransRule(int year, RuleLine rule) {
            this.year = year;
            this.rule = rule;
            this.ldt = rule.toDateTime(year);
            this.ldtSecs = ldt.toEpochSecond(ZoneOffset.UTC);
        }

        ZoneOffsetTransition toTransition(ZoneOffset standardOffset, int savingsBeforeSecs, int negativeSavings) {
            // copy of code in ZoneOffsetTransitionRule to avoid infinite loop
            ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds(
                standardOffset.getTotalSeconds() + savingsBeforeSecs);
            ZoneOffset offsetAfter = ZoneOffset.ofTotalSeconds(
                standardOffset.getTotalSeconds() + rule.savingsAmount - negativeSavings);
            LocalDateTime dt = rule.timeDefinition
                                   .createDateTime(ldt, standardOffset, wallOffset);
            return ZoneOffsetTransition.of(dt, wallOffset, offsetAfter);
        }

        long toEpochSecond(ZoneOffset stdOffset, int savingsBeforeSecs) {
            switch(rule.timeDefinition) {
            case UTC:      return ldtSecs;
            case STANDARD: return ldtSecs - stdOffset.getTotalSeconds();
            default:       return ldtSecs - (stdOffset.getTotalSeconds() + savingsBeforeSecs); // WALL
            }
        }

        /**
         * Tests if this a real transition with the active savings in seconds
         *
         * @param savingsBefore the active savings in seconds
         * @param negativeSavings minimum savings in the rule, usually zero, but negative if negative DST is
         *                   in effect.
         * @return true, if savings changes
         */
        boolean isTransition(int savingsBefore, int negativeSavings) {
            return rule.savingsAmount - negativeSavings != savingsBefore;
        }

        public int compareTo(TransRule other) {
            return (ldtSecs < other.ldtSecs)? -1 : ((ldtSecs == other.ldtSecs) ? 0 : 1);
        }
    }

    private ZoneRules buildRules(String zoneId, List<ZoneLine> zones) {
        if (zones.isEmpty()) {
            throw new IllegalStateException("No available zone window");
        }
        final List<ZoneOffsetTransition> standardTransitionList = new ArrayList<>(4);
        final List<ZoneOffsetTransition> transitionList = new ArrayList<>(256);
        final List<ZoneOffsetTransitionRule> lastTransitionRuleList = new ArrayList<>(2);

        final ZoneLine zone0 = zones.get(0);
        // initialize the standard offset, wallOffset and savings for loop

        //ZoneOffset stdOffset = zone0.standardOffset;
        ZoneOffset stdOffset = ZoneOffset.ofTotalSeconds(zone0.stdOffsetSecs);

        int savings = zone0.fixedSavingsSecs;
        ZoneOffset wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings);

        // start ldt of each zone window
        LocalDateTime zoneStart = LocalDateTime.MIN;

        // first standard offset
        ZoneOffset firstStdOffset = stdOffset;
        // first wall offset
        ZoneOffset firstWallOffset = wallOffset;

        for (ZoneLine zone : zones) {
            // Adjust stdOffset, if negative DST is observed. It should be either
            // fixed amount, or expressed in the named Rules.
            int negativeSavings = Math.min(zone.fixedSavingsSecs, findNegativeSavings(zoneStart, zone));
            if (negativeSavings < 0) {
                zone.stdOffsetSecs += negativeSavings;
                if (zone.fixedSavingsSecs < 0) {
                    zone.fixedSavingsSecs = 0;
                }
            }

            // check if standard offset changed, update it if yes
            ZoneOffset stdOffsetPrev = stdOffset;  // for effectiveSavings check
            if (zone.stdOffsetSecs != stdOffset.getTotalSeconds()) {
                ZoneOffset stdOffsetNew = ZoneOffset.ofTotalSeconds(zone.stdOffsetSecs);
                standardTransitionList.add(
                    ZoneOffsetTransition.of(
                        LocalDateTime.ofEpochSecond(zoneStart.toEpochSecond(wallOffset),
                                                    0,
                                                    stdOffset),
                        stdOffset,
                        stdOffsetNew));
                stdOffset = stdOffsetNew;
            }

            LocalDateTime zoneEnd;
            if (zone.year == Year.MAX_VALUE) {
                zoneEnd = LocalDateTime.MAX;
            } else {
                zoneEnd = zone.toDateTime();
            }
            if (zoneEnd.compareTo(zoneStart) < 0) {
                throw new IllegalStateException("Windows must be in date-time order: " +
                        zoneEnd + " < " + zoneStart);
            }
            // calculate effective savings at the start of the window
            List<TransRule> trules = null;
            List<TransRule> lastRules = null;

            int effectiveSavings = zone.fixedSavingsSecs;
            if (zone.savingsRule != null) {
                List<RuleLine> tzdbRules = rules.get(zone.savingsRule);
                if (tzdbRules == null) {
                   throw new IllegalArgumentException("<Rule> not found: " +
                                                       zone.savingsRule);
                }
                trules = new ArrayList<>(256);
                lastRules = new ArrayList<>(2);
                int lastRulesStartYear = Year.MIN_VALUE;

                // merge the rules to transitions
                for (RuleLine rule : tzdbRules) {
                    if (rule.startYear > zoneEnd.getYear()) {
                        // rules will not be used for this zone entry
                        continue;
                    }
                    rule.adjustToForwards(2004);  // irrelevant, treat as leap year

                    int startYear = rule.startYear;
                    int endYear = rule.endYear;
                    if (zoneEnd.equals(LocalDateTime.MAX)) {
                        if (endYear == Year.MAX_VALUE) {
                            endYear = startYear;
                            lastRules.add(new TransRule(endYear, rule));
                        }
                        lastRulesStartYear = Math.max(startYear, lastRulesStartYear);
                    } else {
                        if (endYear == Year.MAX_VALUE) {
                            //endYear = zoneEnd.getYear();
                            endYear = zone.year;
                        }
                    }
                    int year = startYear;
                    while (year <= endYear) {
                        trules.add(new TransRule(year, rule));
                        year++;
                    }
                }

                // last rules, fill the gap years between different last rules
                if (zoneEnd.equals(LocalDateTime.MAX)) {
                    lastRulesStartYear = Math.max(lastRulesStartYear, zoneStart.getYear()) + 1;
                    for (TransRule rule : lastRules) {
                        if (rule.year <= lastRulesStartYear) {
                            int year = rule.year;
                            while (year <= lastRulesStartYear) {
                                trules.add(new TransRule(year, rule.rule));
                                year++;
                            }
                            rule.year = lastRulesStartYear;
                            rule.ldt = rule.rule.toDateTime(year);
                            rule.ldtSecs = rule.ldt.toEpochSecond(ZoneOffset.UTC);
                        }
                    }
                    Collections.sort(lastRules);
                }
                // sort the merged rules
                Collections.sort(trules);

                effectiveSavings = -negativeSavings;
                for (TransRule rule : trules) {
                    if (rule.toEpochSecond(stdOffsetPrev, savings) >
                        zoneStart.toEpochSecond(wallOffset)) {
                        // previous savings amount found, which could be the
                        // savings amount at the instant that the window starts
                        // (hence isAfter)
                        break;
                    }
                    effectiveSavings = rule.rule.savingsAmount - negativeSavings;
                }
            }
            // check if the start of the window represents a transition
            ZoneOffset effectiveWallOffset =
                ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + effectiveSavings);

            if (!wallOffset.equals(effectiveWallOffset)) {
                transitionList.add(ZoneOffsetTransition.of(zoneStart,
                                                           wallOffset,
                                                           effectiveWallOffset));
            }
            savings = effectiveSavings;
            // apply rules within the window
            if (trules != null) {
                long zoneStartEpochSecs = zoneStart.toEpochSecond(wallOffset);
                for (TransRule trule : trules) {
                    if (trule.isTransition(savings, negativeSavings)) {
                        long epochSecs = trule.toEpochSecond(stdOffset, savings);
                        if (epochSecs < zoneStartEpochSecs ||
                            epochSecs >= zone.toDateTimeEpochSecond(savings)) {
                            continue;
                        }
                        transitionList.add(trule.toTransition(stdOffset, savings, negativeSavings));
                        savings = trule.rule.savingsAmount - negativeSavings;
                    }
                }
            }
            if (lastRules != null) {
                for (TransRule trule : lastRules) {
                    lastTransitionRuleList.add(trule.rule.toTransitionRule(stdOffset, savings, negativeSavings));
                    savings = trule.rule.savingsAmount - negativeSavings;
                }
            }

            // finally we can calculate the true end of the window, passing it to the next window
            wallOffset = ZoneOffset.ofTotalSeconds(stdOffset.getTotalSeconds() + savings);
            zoneStart = LocalDateTime.ofEpochSecond(zone.toDateTimeEpochSecond(savings),
                                                    0,
                                                    wallOffset);
        }
        return new ZoneRules(firstStdOffset,
                             firstWallOffset,
                             standardTransitionList,
                             transitionList,
                             lastTransitionRuleList);
    }

    /**
     * Find the minimum negative savings in named Rules for a Zone. Savings are only
     * looked at for the period of the subject Zone.
     *
     * @param zoneStart start LDT of the zone
     * @param zl ZoneLine to look at
     */
    private int findNegativeSavings(LocalDateTime zoneStart, ZoneLine zl) {
        int negativeSavings = 0;
        LocalDateTime zoneEnd = zl.toDateTime();

        if (zl.savingsRule != null) {
            List<RuleLine> rlines = rules.get(zl.savingsRule);
            if (rlines == null) {
                throw new IllegalArgumentException("<Rule> not found: " +
                        zl.savingsRule);
            }

            negativeSavings = Math.min(0, rlines.stream()
                    .filter(l -> windowOverlap(l, zoneStart.getYear(), zoneEnd.getYear()))
                    .map(l -> l.savingsAmount)
                    .min(Comparator.naturalOrder())
                    .orElse(0));
        }

        return negativeSavings;
    }

    private boolean windowOverlap(RuleLine ruleLine, int zoneStartYear, int zoneEndYear) {
        boolean overlap = zoneStartYear <= ruleLine.startYear && zoneEndYear >= ruleLine.startYear ||
                          zoneStartYear <= ruleLine.endYear && zoneEndYear >= ruleLine.endYear;

        return overlap;
    }
}
