/*
 * Copyright (C) 2019 The Android Open Source Project
 *
 * Licensed 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.
 */

import com.google.common.base.Joiner;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.MappedByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

/**
 * Dumps out the contents of a tzfile in a CSV form.
 *
 * <p>This class contains a near copy of logic found in Android's ZoneInfo class.
 */
public class TzFileDumper {

    public static void main(String[] args) throws Exception {
        if (args.length != 2) {
            System.err.println("usage: java TzFileDumper <tzfile|dir> <output file|output dir>");
            System.exit(0);
        }

        File input = new File(args[0]);
        File output = new File(args[1]);
        if (input.isDirectory()) {
            if (!output.isDirectory()) {
                System.err.println("If first args is a directory, second arg must be a directory");
                System.exit(1);
            }

            for (File inputFile : input.listFiles()) {
                if (inputFile.isFile()) {
                    File outputFile = new File(output, inputFile.getName() + ".csv");
                    try {
                        new TzFileDumper(inputFile, outputFile).execute();
                    } catch (IOException e) {
                        System.err.println("Error processing:" + inputFile);
                    }
                }
            }
        } else {
            new TzFileDumper(input, output).execute();
        }
    }

    private final File inputFile;
    private final File outputFile;

    private TzFileDumper(File inputFile, File outputFile) {
        this.inputFile = inputFile;
        this.outputFile = outputFile;
    }

    private void execute() throws IOException {
        System.out.println("Dumping " + inputFile + " to " + outputFile);
        MappedByteBuffer mappedTzFile = ZoneSplitter.createMappedByteBuffer(inputFile);

        try (Writer fileWriter = new OutputStreamWriter(
                new FileOutputStream(outputFile), StandardCharsets.UTF_8)) {
            Header header32Bit = readHeader(mappedTzFile);
            List<Transition> transitions32Bit = read32BitTransitions(mappedTzFile, header32Bit);
            List<Type> types32Bit = readTypes(mappedTzFile, header32Bit);
            skipUninteresting32BitData(mappedTzFile, header32Bit);
            types32Bit = mergeTodInfo(mappedTzFile, header32Bit, types32Bit);

            writeCsvRow(fileWriter, "File format version: " + (char) header32Bit.tzh_version);
            writeCsvRow(fileWriter);
            writeCsvRow(fileWriter, "32-bit data");
            writeCsvRow(fileWriter);
            writeTypes(types32Bit, fileWriter);
            writeCsvRow(fileWriter);
            writeTransitions(transitions32Bit, types32Bit, fileWriter);
            writeCsvRow(fileWriter);

            if (header32Bit.tzh_version >= '2') {
                Header header64Bit = readHeader(mappedTzFile);
                List<Transition> transitions64Bit = read64BitTransitions(mappedTzFile, header64Bit);
                List<Type> types64Bit = readTypes(mappedTzFile, header64Bit);
                skipUninteresting64BitData(mappedTzFile, header64Bit);
                types64Bit = mergeTodInfo(mappedTzFile, header64Bit, types64Bit);

                writeCsvRow(fileWriter, "64-bit data");
                writeCsvRow(fileWriter);
                writeTypes(types64Bit, fileWriter);
                writeCsvRow(fileWriter);
                writeTransitions(transitions64Bit, types64Bit, fileWriter);
            }
        }
    }

    private Header readHeader(MappedByteBuffer mappedTzFile) throws IOException {
        // Variable names beginning tzh_ correspond to those in "tzfile.h".
        // Check tzh_magic.
        int tzh_magic = mappedTzFile.getInt();
        if (tzh_magic != 0x545a6966) { // "TZif"
            throw new IOException("File=" + inputFile + " has an invalid header=" + tzh_magic);
        }

        byte tzh_version = mappedTzFile.get();

        // Skip the uninteresting part of the header.
        mappedTzFile.position(mappedTzFile.position() + 15);
        int tzh_ttisgmtcnt = mappedTzFile.getInt();
        int tzh_ttisstdcnt = mappedTzFile.getInt();
        int tzh_leapcnt = mappedTzFile.getInt();

        // Read the sizes of the arrays we're about to read.
        int tzh_timecnt = mappedTzFile.getInt();
        // Arbitrary ceiling to prevent allocating memory for corrupt data.
        // 2 per year with 2^32 seconds would give ~272 transitions.
        final int MAX_TRANSITIONS = 2000;
        if (tzh_timecnt < 0 || tzh_timecnt > MAX_TRANSITIONS) {
            throw new IOException(
                    "File=" + inputFile + " has an invalid number of transitions=" + tzh_timecnt);
        }

        int tzh_typecnt = mappedTzFile.getInt();
        final int MAX_TYPES = 256;
        if (tzh_typecnt < 1) {
            throw new IOException("ZoneInfo requires at least one type to be provided for each"
                    + " timezone but could not find one for '" + inputFile + "'");
        } else if (tzh_typecnt > MAX_TYPES) {
            throw new IOException(
                    "File=" + inputFile + " has too many types=" + tzh_typecnt);
        }

        int tzh_charcnt = mappedTzFile.getInt();

        return new Header(
                tzh_version, tzh_ttisgmtcnt, tzh_ttisstdcnt, tzh_leapcnt, tzh_timecnt, tzh_typecnt,
                tzh_charcnt);
    }

    private List<Transition> read32BitTransitions(MappedByteBuffer mappedTzFile, Header header)
            throws IOException {

        // Read the data.
        int[] transitionTimes = new int[header.tzh_timecnt];
        fillIntArray(mappedTzFile, transitionTimes);

        byte[] typeIndexes = new byte[header.tzh_timecnt];
        mappedTzFile.get(typeIndexes);

        // Convert int times to longs
        long[] transitionTimesLong = new long[header.tzh_timecnt];
        for (int i = 0; i < header.tzh_timecnt; ++i) {
            transitionTimesLong[i] = transitionTimes[i];
        }

        return createTransitions(header, transitionTimesLong, typeIndexes);
    }

    private List<Transition> createTransitions(Header header,
            long[] transitionTimes, byte[] typeIndexes) throws IOException {
        List<Transition> transitions = new ArrayList<>();
        for (int i = 0; i < header.tzh_timecnt; ++i) {
            if (i > 0 && transitionTimes[i] <= transitionTimes[i - 1]) {
                throw new IOException(
                        inputFile + " transition at " + i + " is not sorted correctly, is "
                                + transitionTimes[i] + ", previous is " + transitionTimes[i - 1]);
            }

            int typeIndex = typeIndexes[i] & 0xff;
            if (typeIndex >= header.tzh_typecnt) {
                throw new IOException(inputFile + " type at " + i + " is not < "
                        + header.tzh_typecnt + ", is " + typeIndex);
            }

            Transition transition = new Transition(transitionTimes[i], typeIndex);
            transitions.add(transition);
        }
        return transitions;
    }

    private List<Transition> read64BitTransitions(MappedByteBuffer mappedTzFile, Header header)
            throws IOException {
        long[] transitionTimes = new long[header.tzh_timecnt];
        fillLongArray(mappedTzFile, transitionTimes);

        byte[] typeIndexes = new byte[header.tzh_timecnt];
        mappedTzFile.get(typeIndexes);

        return createTransitions(header, transitionTimes, typeIndexes);
    }

    private void writeTransitions(List<Transition> transitions, List<Type> types, Writer fileWriter)
            throws IOException {

        List<Object[]> rows = new ArrayList<>();
        for (Transition transition : transitions) {
            Type type = types.get(transition.typeIndex);
            Object[] row = new Object[] {
                    transition.transitionTimeSeconds,
                    transition.typeIndex,
                    formatTimeSeconds(transition.transitionTimeSeconds),
                    formatDurationSeconds(type.gmtOffsetSeconds),
                    formatIsDst(type.isDst),
            };
            rows.add(row);
        }

        writeCsvRow(fileWriter, "Transitions");
        writeTuplesCsv(fileWriter, rows, "transition", "type", "[UTC time]", "[Type offset]",
                "[Type isDST]");
    }

    private List<Type> readTypes(MappedByteBuffer mappedTzFile, Header header) throws IOException {
        List<Type> types = new ArrayList<>();
        for (int i = 0; i < header.tzh_typecnt; ++i) {
            int gmtOffsetSeconds = mappedTzFile.getInt();
            byte isDst = mappedTzFile.get();
            if (isDst != 0 && isDst != 1) {
                throw new IOException(inputFile + " dst at " + i + " is not 0 or 1, is " + isDst);
            }

            // We skip the abbreviation index.
            mappedTzFile.get();

            types.add(new Type(gmtOffsetSeconds, isDst));
        }
        return types;
    }

    private static void skipUninteresting32BitData(MappedByteBuffer mappedTzFile, Header header) {
        mappedTzFile.get(new byte[header.tzh_charcnt]);
        int leapInfoSize = 4 + 4;
        mappedTzFile.get(new byte[header.tzh_leapcnt * leapInfoSize]);
    }


    private void skipUninteresting64BitData(MappedByteBuffer mappedTzFile, Header header) {
        mappedTzFile.get(new byte[header.tzh_charcnt]);
        int leapInfoSize = 8 + 4;
        mappedTzFile.get(new byte[header.tzh_leapcnt * leapInfoSize]);
    }

    /**
     * Populate ttisstd and ttisgmt information by copying {@code types} and populating those fields
     * in the copies.
     */
    private static List<Type> mergeTodInfo(
            MappedByteBuffer mappedTzFile, Header header, List<Type> types) {

        byte[] ttisstds = new byte[header.tzh_ttisstdcnt];
        mappedTzFile.get(ttisstds);
        byte[] ttisgmts = new byte[header.tzh_ttisgmtcnt];
        mappedTzFile.get(ttisgmts);

        List<Type> outputTypes = new ArrayList<>();
        for (int i = 0; i < types.size(); i++) {
            Type inputType = types.get(i);
            Byte ttisstd = ttisstds.length == 0 ? null : ttisstds[i];
            Byte ttisgmt = ttisgmts.length == 0 ? null : ttisgmts[i];
            Type outputType =
                    new Type(inputType.gmtOffsetSeconds, inputType.isDst, ttisstd, ttisgmt);
            outputTypes.add(outputType);
        }
        return outputTypes;
    }

    private void writeTypes(List<Type> types, Writer fileWriter) throws IOException {
        List<Object[]> rows = new ArrayList<>();
        for (Type type : types) {
            Object[] row = new Object[] {
                    type.gmtOffsetSeconds,
                    type.isDst,
                    nullToEmptyString(type.ttisgmt),
                    nullToEmptyString(type.ttisstd),
                    formatDurationSeconds(type.gmtOffsetSeconds),
                    formatIsDst(type.isDst),
            };
            rows.add(row);
        }

        writeCsvRow(fileWriter, "Types");
        writeTuplesCsv(
                fileWriter, rows, "gmtOffset (seconds)", "isDst", "ttisgmt", "ttisstd",
                "[gmtOffset ISO]", "[DST?]");
    }

    private static Object nullToEmptyString(Object object) {
        return object == null ? "" : object;
    }

    private static void fillIntArray(MappedByteBuffer mappedByteBuffer, int[] toFill) {
        for (int i = 0; i < toFill.length; i++) {
            toFill[i] = mappedByteBuffer.getInt();
        }
    }

    private static void fillLongArray(MappedByteBuffer mappedByteBuffer, long[] toFill) {
        for (int i = 0; i < toFill.length; i++) {
            toFill[i] = mappedByteBuffer.getLong();
        }
    }

    private static String formatTimeSeconds(long timeInSeconds) {
        long timeInMillis = timeInSeconds * 1000L;
        return Instant.ofEpochMilli(timeInMillis).toString();
    }

    private static String formatDurationSeconds(int duration) {
        return Duration.ofSeconds(duration).toString();
    }

    private String formatIsDst(byte isDst) {
        return isDst == 0 ? "STD" : "DST";
    }

    private static void writeCsvRow(Writer writer, Object... values) throws IOException {
        writer.append(Joiner.on(',').join(values));
        writer.append('\n');
    }

    private static void writeTuplesCsv(Writer writer, List<Object[]> lines, String... headings)
            throws IOException {

        writeCsvRow(writer, (Object[]) headings);
        for (Object[] line : lines) {
            writeCsvRow(writer, line);
        }
    }

    private static class Header {

        /** The version. Known values are 0 (ASCII NUL), 50 (ASCII '2'), 51 (ASCII '3'). */
        final byte tzh_version;
        final int tzh_timecnt;
        final int tzh_typecnt;
        final int tzh_charcnt;
        final int tzh_leapcnt;
        final int tzh_ttisstdcnt;
        final int tzh_ttisgmtcnt;

        Header(byte tzh_version, int tzh_ttisgmtcnt, int tzh_ttisstdcnt, int tzh_leapcnt,
                int tzh_timecnt, int tzh_typecnt, int tzh_charcnt) {
            this.tzh_version = tzh_version;
            this.tzh_timecnt = tzh_timecnt;
            this.tzh_typecnt = tzh_typecnt;
            this.tzh_charcnt = tzh_charcnt;
            this.tzh_leapcnt = tzh_leapcnt;
            this.tzh_ttisstdcnt = tzh_ttisstdcnt;
            this.tzh_ttisgmtcnt = tzh_ttisgmtcnt;
        }
    }

    private static class Type {

        final int gmtOffsetSeconds;
        final byte isDst;
        final Byte ttisstd;
        final Byte ttisgmt;

        Type(int gmtOffsetSeconds, byte isDst) {
            this(gmtOffsetSeconds, isDst, null, null);
        }

        Type(int gmtOffsetSeconds, byte isDst, Byte ttisstd, Byte ttisgmt) {
            this.gmtOffsetSeconds = gmtOffsetSeconds;
            this.isDst = isDst;
            this.ttisstd = ttisstd;
            this.ttisgmt = ttisgmt;
        }
    }

    private static class Transition {

        final long transitionTimeSeconds;
        final int typeIndex;

        Transition(long transitionTimeSeconds, int typeIndex) {
            this.transitionTimeSeconds = transitionTimeSeconds;
            this.typeIndex = typeIndex;
        }
    }
}