/*
 * Copyright (C) 2007 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.
 */

package com.android.i18n.timezone;

import android.system.ErrnoException;
import com.android.i18n.timezone.internal.BasicLruCache;
import com.android.i18n.timezone.internal.BufferIterator;
import com.android.i18n.timezone.internal.MemoryMappedFile;

import dalvik.annotation.optimization.ReachabilitySensitive;

import libcore.util.NonNull;
import libcore.util.Nullable;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * A class used to initialize the time zone database. This implementation uses the
 * Olson tzdata as the source of time zone information. However, to conserve
 * disk space (inodes) and reduce I/O, all the data is concatenated into a single file,
 * with an index to indicate the starting position of each time zone record.
 *
 * @hide - used to implement TimeZone
 */
@libcore.api.CorePlatformApi
@libcore.api.IntraCoreApi
public final class ZoneInfoDb {

  // VisibleForTesting
  public static final String TZDATA_FILE_NAME = "tzdata";

  private static final ZoneInfoDb DATA = ZoneInfoDb.loadTzDataWithFallback(
          TimeZoneDataFiles.getTimeZoneFilePaths(TZDATA_FILE_NAME));

  // The database reserves 40 bytes for each id.
  private static final int SIZEOF_TZNAME = 40;

  // The database uses 32-bit (4 byte) integers.
  private static final int SIZEOF_TZINT = 4;

  // Each index entry takes up this number of bytes.
  public static final int SIZEOF_INDEX_ENTRY = SIZEOF_TZNAME + 3 * SIZEOF_TZINT;

  /**
   * {@code true} if {@link #close()} has been called meaning the instance cannot provide any
   * data.
   */
  private boolean closed;

  /**
   * Rather than open, read, and close the big data file each time we look up a time zone,
   * we map the big data file during startup, and then just use the MemoryMappedFile.
   *
   * At the moment, this "big" data file is about 500 KiB. At some point, that will be small
   * enough that we could just keep the byte[] in memory, but using mmap(2) like this has the
   * nice property that even if someone replaces the file under us (because multiple gservices
   * updates have gone out, say), we still get a consistent (if outdated) view of the world.
   */
  // Android-added: @ReachabilitySensitive
  @ReachabilitySensitive
  private MemoryMappedFile mappedFile;

  private String version;

  /**
   * The 'ids' array contains time zone ids sorted alphabetically, for binary searching.
   * The other two arrays are in the same order. 'byteOffsets' gives the byte offset
   * of each time zone, and 'rawUtcOffsetsCache' gives the time zone's raw UTC offset.
   */
  private String[] ids;
  private int[] byteOffsets;
  private int[] rawUtcOffsetsCache; // Access this via getRawUtcOffsets instead.

  /**
   * ZoneInfo objects are worth caching because they are expensive to create.
   * See http://b/8270865 for context.
   */
  private final static int CACHE_SIZE = 1;
  private final BasicLruCache<String, ZoneInfoData> cache =
          new BasicLruCache<String, ZoneInfoData>(CACHE_SIZE) {
    @Override
    protected ZoneInfoData create(String id) {
      try {
        return makeZoneInfoDataUncached(id);
      } catch (IOException e) {
        throw new IllegalStateException("Unable to load timezone for ID=" + id, e);
      }
    }
  };

  /**
   * Obtains the singleton instance.
   *
   * @hide
   */
  @libcore.api.CorePlatformApi
  @libcore.api.IntraCoreApi
  public static @NonNull ZoneInfoDb getInstance() {
    return DATA;
  }

  /**
   * Loads the data at the specified paths in order, returning the first valid one as a
   * {@link ZoneInfoDb} object. If there is no valid one found a basic fallback instance is created
   * containing just GMT.
   */
  public static ZoneInfoDb loadTzDataWithFallback(String... paths) {
    for (String path : paths) {
      ZoneInfoDb tzData = new ZoneInfoDb();
      if (tzData.loadData(path)) {
        return tzData;
      }
    }

    // We didn't find any usable tzdata on disk, so let's just hard-code knowledge of "GMT".
    // This is actually implemented in TimeZone itself, so if this is the only time zone
    // we report, we won't be asked any more questions.
    // !! System.logE("Couldn't find any " + TZDATA_FILE_NAME + " file!");
    return ZoneInfoDb.createFallback();
  }

  /**
   * Loads the data at the specified path and returns the {@link ZoneInfoDb} object if it is valid,
   * otherwise {@code null}.
   */
  // VisibleForTesting
  public static ZoneInfoDb loadTzData(String path) {
    ZoneInfoDb tzData = new ZoneInfoDb();
    if (tzData.loadData(path)) {
      return tzData;
    }
    return null;
  }

  private static ZoneInfoDb createFallback() {
    ZoneInfoDb tzData = new ZoneInfoDb();
    tzData.populateFallback();
    return tzData;
  }

  private ZoneInfoDb() {
  }

  /**
   * Visible for testing.
   */
  public BufferIterator getBufferIterator(String id) {
    checkNotClosed();

    // Work out where in the big data file this time zone is.
    int index = Arrays.binarySearch(ids, id);
    if (index < 0) {
      return null;
    }

    int byteOffset = byteOffsets[index];
    BufferIterator it = mappedFile.bigEndianIterator();
    it.skip(byteOffset);
    return it;
  }

  private void populateFallback() {
    version = "missing";
    ids = new String[] { "GMT" };
    byteOffsets = rawUtcOffsetsCache = new int[1];
  }

  /**
   * Loads the data file at the specified path. If the data is valid {@code true} will be
   * returned and the {@link ZoneInfoDb} instance can be used. If {@code false} is returned then the
   * ZoneInfoDB instance is left in a closed state and must be discarded.
   */
  private boolean loadData(String path) {
    try {
      mappedFile = MemoryMappedFile.mmapRO(path);
    } catch (ErrnoException errnoException) {
      return false;
    }
    try {
      readHeader();
      return true;
    } catch (Exception ex) {
      close();

      // Something's wrong with the file.
      // Log the problem and return false so we try the next choice.
      // !! System.logE(TZDATA_FILE_NAME + " file \"" + path + "\" was present but invalid!", ex);
      return false;
    }
  }

  private void readHeader() throws IOException {
    // byte[12] tzdata_version  -- "tzdata2012f\0"
    // int index_offset
    // int data_offset
    // int final_offset
    BufferIterator it = mappedFile.bigEndianIterator();

    try {
      byte[] tzdata_version = new byte[12];
      it.readByteArray(tzdata_version, 0, tzdata_version.length);
      String magic = new String(tzdata_version, 0, 6, StandardCharsets.US_ASCII);
      if (!magic.equals("tzdata") || tzdata_version[11] != 0) {
        throw new IOException("bad tzdata magic: " + Arrays.toString(tzdata_version));
      }
      version = new String(tzdata_version, 6, 5, StandardCharsets.US_ASCII);

      final int fileSize = mappedFile.size();
      int index_offset = it.readInt();
      int data_offset = it.readInt();
      int final_offset = it.readInt();

      if (index_offset >= data_offset
              || data_offset >= final_offset
              || final_offset > fileSize) {
        throw new IOException("Invalid offset: index_offset=" + index_offset
                + ", data_offset=" + data_offset + ", final_offset=" + final_offset
                + ", fileSize=" + fileSize);
      }

      readIndex(it, index_offset, data_offset);
    } catch (IndexOutOfBoundsException e) {
      throw new IOException("Invalid read from data file", e);
    }
  }

  private void readIndex(BufferIterator it, int indexOffset, int dataOffset) throws IOException {
    it.seek(indexOffset);

    byte[] idBytes = new byte[SIZEOF_TZNAME];
    int indexSize = (dataOffset - indexOffset);
    if (indexSize % SIZEOF_INDEX_ENTRY != 0) {
      throw new IOException("Index size is not divisible by " + SIZEOF_INDEX_ENTRY
              + ", indexSize=" + indexSize);
    }
    int entryCount = indexSize / SIZEOF_INDEX_ENTRY;

    byteOffsets = new int[entryCount];
    ids = new String[entryCount];

    for (int i = 0; i < entryCount; i++) {
      // Read the fixed length timezone ID.
      it.readByteArray(idBytes, 0, idBytes.length);

      // Read the offset into the file where the data for ID can be found.
      byteOffsets[i] = it.readInt();
      byteOffsets[i] += dataOffset;

      int length = it.readInt();
      if (length < 44) {
        throw new IOException("length in index file < sizeof(tzhead)");
      }
      it.skip(4); // Skip the unused 4 bytes that used to be the raw offset.

      // Calculate the true length of the ID.
      int len = 0;
      while (len < idBytes.length && idBytes[len] != 0) {
        len++;
      }
      if (len == 0) {
        throw new IOException("Invalid ID at index=" + i);
      }
      String zoneId = new String(idBytes, 0, len, StandardCharsets.US_ASCII);
      // intern() zone Ids because they are a fixed set of well-known strings that are used in
      // other low-level library calls.
      ids[i] = zoneId.intern();
      if (i > 0) {
        if (ids[i].compareTo(ids[i - 1]) <= 0) {
          throw new IOException("Index not sorted or contains multiple entries with the same ID"
                  + ", index=" + i + ", ids[i]=" + ids[i] + ", ids[i - 1]=" + ids[i - 1]);
        }
      }
    }
  }


  /**
   * Validate the data at the specified path. Throws {@link IOException} if it's not valid.
   */
  @libcore.api.CorePlatformApi
  public static void validateTzData(@NonNull String path) throws IOException {
    ZoneInfoDb tzData = ZoneInfoDb.loadTzData(path);
    if (tzData == null) {
      throw new IOException("failed to read tzData at " + path);
    }
    try {
      tzData.validate();
    } finally {
      tzData.close();
    }
  }

  private void validate() throws IOException {
    checkNotClosed();
    // Validate the data in the tzdata file by loading each and every zone.
    for (String id : getAvailableIDs()) {
      ZoneInfoData zoneInfoData = makeZoneInfoDataUncached(id);
      if (zoneInfoData == null) {
        throw new IOException("Unable to find data for ID=" + id);
      }
    }
  }

  ZoneInfoData makeZoneInfoDataUncached(String id) throws IOException {
    BufferIterator it = getBufferIterator(id);
    if (it == null) {
      return null;
    }

    return ZoneInfoData.readTimeZone(id, it);
  }

  /**
   * Returns an array containing all time zone ids sorted in lexicographical order for
   * binary searching.
   *
   * @hide
   */
  @libcore.api.IntraCoreApi
  public @NonNull String @NonNull[] getAvailableIDs() {
    checkNotClosed();
    return ids.clone();
  }

  /**
   * Returns ids of all time zones with the given raw UTC offset.
   *
   * @hide
   */
  @libcore.api.IntraCoreApi
  public @NonNull String @NonNull[] getAvailableIDs(int rawUtcOffset) {
    checkNotClosed();
    List<String> matches = new ArrayList<String>();
    int[] rawUtcOffsets = getRawUtcOffsets();
    for (int i = 0; i < rawUtcOffsets.length; ++i) {
      if (rawUtcOffsets[i] == rawUtcOffset) {
        matches.add(ids[i]);
      }
    }
    return matches.toArray(new String[matches.size()]);
  }

  private synchronized int[] getRawUtcOffsets() {
    if (rawUtcOffsetsCache != null) {
      return rawUtcOffsetsCache;
    }
    rawUtcOffsetsCache = new int[ids.length];
    for (int i = 0; i < ids.length; ++i) {
      // This creates a TimeZone, which is quite expensive. Hence the cache.
      // Note that icu4c does the same (without the cache), so if you're
      // switching this code over to icu4j you should check its performance.
      // Telephony shouldn't care, but someone converting a bunch of calendar
      // events might.
      rawUtcOffsetsCache[i] = cache.get(ids[i]).getRawOffset();
    }
    return rawUtcOffsetsCache;
  }

  /**
   * Returns the tzdb version in use.
   */
  @libcore.api.CorePlatformApi
  public @NonNull String getVersion() {
    checkNotClosed();
    return version;
  }

  /**
   * Creates {@link ZoneInfoData} object from the time zone {@code id}. Returns null if the id
   * is not found.
   *
   * @hide
   */
  @libcore.api.CorePlatformApi
  @libcore.api.IntraCoreApi
  public @Nullable ZoneInfoData makeZoneInfoData(@NonNull String id) {
    checkNotClosed();
    ZoneInfoData zoneInfoData = cache.get(id);
    // The object from the cache is not cloned because ZoneInfoData is immutable.
    // Note that zoneInfoData can be null here.
    return zoneInfoData;
  }

  @libcore.api.CorePlatformApi
  public boolean hasTimeZone(@NonNull String id) {
    checkNotClosed();
    return Arrays.binarySearch(ids, id) >= 0;
  }

  // VisibleForTesting
  public void close() {
    if (!closed) {
      closed = true;

      // Clear state that takes up appreciable heap.
      ids = null;
      byteOffsets = null;
      rawUtcOffsetsCache = null;
      cache.evictAll();

      // Remove the mapped file (if needed).
      if (mappedFile != null) {
        try {
          mappedFile.close();
        } catch (ErrnoException ignored) {
        }
        mappedFile = null;
      }
    }
  }

  private void checkNotClosed() throws IllegalStateException {
    if (closed) {
      throw new IllegalStateException("ZoneInfoDB instance is closed");
    }
  }

  @Override
  protected void finalize() throws Throwable {
    try {
      close();
    } finally {
      super.finalize();
    }
  }
}
