package com.google.android.apps.common.testing.accessibility.framework.strings;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.ResourceBundle;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

/**
 * A {@link ResourceBundle} that reads strings from Android-style resource files.
 */
public class AndroidXMLResourceBundle extends ResourceBundle {
  private static final String ANDORID_STRING_TAG_NAME = "string";
  private static final String ANDROID_STRING_NAME_ATTRIBUTE = "name";

  private final Properties properties = new Properties();

  private AndroidXMLResourceBundle(InputStream inputStream) throws IOException {
    checkNotNull(inputStream);
    Document document = getDocument(inputStream);
    inputStream.close();
    addStringsToProperties(document, properties);
  }

  @Override
  protected @Nullable Object handleGetObject(String key) {
    return properties.getProperty(key);
  }

  @Override
  public Enumeration<String> getKeys() {
    return Collections.enumeration(properties.stringPropertyNames());
  }

  /**
   * Parses an xml input and returns a {@link Document}.
   *
   * @param inputStream an {@link InputStream} to parse XML form
   * @return a {@link Document} containing the DOM for the input
   * @throws {@link RuntimeException} if the xml could not be parsed
   */
  private static Document getDocument(InputStream inputStream) {
    Document document = null;
    try {
      DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
      documentBuilderFactory.setIgnoringElementContentWhitespace(true);
      documentBuilderFactory.setIgnoringComments(true);
      DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
      InputSource inputSource = new InputSource(inputStream);
      document = documentBuilder.parse(inputSource);
    } catch (SAXException | ParserConfigurationException | IOException e) {
      throw new RuntimeException("Could not read xml properties file", e);
    }
    return document;
  }

  /**
   * For every <string> tag in the given document, adds a property to the given {@link Properties}
   * with the tag's name attribute as the key and the tag's text as the value.
   *
   * @param document a {@link Document} containing android style <string> tags
   * @param properties a {@link Properties} to add strings found in the given {@link Document} to
   */
  // dereference of possibly-null reference value
  // incompatible types in argument.
  @SuppressWarnings({"nullness:dereference.of.nullable", "nullness:argument.type.incompatible"})
  private static void addStringsToProperties(Document document, Properties properties) {
    NodeList stringNodes = document.getElementsByTagName(ANDORID_STRING_TAG_NAME);
    for (int i = 0; i < stringNodes.getLength(); i++) {
      Node node = stringNodes.item(i);
      // dereference of possibly-null reference node
      // dereference of possibly-null reference node.getAttributes()
      // dereference of possibly-null reference
      // node.getAttributes().getNamedItem(ANDROID_STRING_NAME_ATTRIBUTE)
      @SuppressWarnings("nullness:dereference.of.nullable")
      String key = node.getAttributes().getNamedItem(ANDROID_STRING_NAME_ATTRIBUTE).getNodeValue();

      String value = node.getTextContent();
      // Android trims whitespace throughout (getTextContent does it internally but not at the ends)
      // so we trim for parity
      value = value.trim();
      // The XML parser does not unescape quotes, so we replace \" with " here for Android parity
      value = value.replace("\\\"", "\"");
      // The XML parser does not unescape quotes, so we replace \' with ' here for Android parity
      value = value.replace("\\'", "'");
      properties.setProperty(key, value);
    }
  }

  /**
   * A {@link ResourceBundle.Control} implementation that can create a {@link ResourceBundle}
   * containing the localized strings from an android values XML file.
   */
  static class Control extends ResourceBundle.Control {
    private static final String XML_FORMAT = "xml";
    private static final List<String> ACCEPTED_FORMATS =
        Collections.unmodifiableList(Arrays.asList(XML_FORMAT));

    @Override
    public List<String> getFormats(String baseName) {
      checkNotNull(baseName);
      return ACCEPTED_FORMATS;
    }

    @Override
    public @Nullable ResourceBundle newBundle(
        String baseName, Locale locale, String format, ClassLoader loader, boolean reload)
        throws IllegalAccessException, InstantiationException, IOException {
      String resource = toResourceName(toBundleName(baseName, locale), XML_FORMAT);
      if (resource != null) {
        URL url = loader.getResource(resource);
        if (url != null) {
          URLConnection urlConnection = url.openConnection();
          if (urlConnection != null) {
            if (reload) {
              urlConnection.setUseCaches(false);
            }
            InputStream inputStream = urlConnection.getInputStream();
            if (inputStream != null) {
              ResourceBundle bundle = new AndroidXMLResourceBundle(inputStream);
              inputStream.close();
              return bundle;
            }
          }
        }
      }
      return null;
    }

    @Override
    public @Nullable Locale getFallbackLocale(String baseName, Locale locale) {
      // When android has no corresponding locale, it falls back to the ROOT (values/), so this
      // should not fall back to Locale.getDefault() for parity
      return null;
    }

    @Override
    public String toBundleName(String baseName, Locale locale) {
      checkNotNull(locale);
      checkNotNull(baseName);
      checkArgument(!baseName.isEmpty(), "Attempted to get resource name for empty base name");
      String language = locale.getLanguage();
      StringBuilder localeName = new StringBuilder();
      if (!language.isEmpty()) {
        localeName.append("-");
        localeName.append(language);
        String country = locale.getCountry();
        if (!country.isEmpty()) {
          localeName.append("-r");
          localeName.append(country);
        }
      }
      int packageNameDividerIndex = baseName.lastIndexOf('.');
      String packageName = baseName.substring(0, packageNameDividerIndex);
      String fileName = baseName.substring(packageNameDividerIndex + 1);
      return String.format("%s.res.values%s.%s", packageName, localeName, fileName);
    }

    /**
     * Returns a {@link String} name derived from the android package name and file name
     * that can be passed to any {@link ResourceBundle#getBundle} method for use with this class.
     *
     * @param packageName the android package where resources can be found
     * @param fileName the name of the xml strings file without an extension
     *                 (e.g. for res/values/strings.xml, fileName = "strings")
     * @return a {@link String} to be used with {@link ResourceBundle#getBundle}
     */
    static String getBaseName(String packageName, String fileName) {
      return String.format("%s.%s", packageName, fileName);
    }
  }
}