/*
 * Copyright (c) 2008, Aaron Digulla
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 *       notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in
 *       the documentation and/or other materials provided with the
 *       distribution.
 *     * Neither the name of Aaron Digulla nor the names of its
 *       contributors may be used to endorse or promote products derived
 *       from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
 * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
 * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
 * OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 */
package de.pdark.decentxml;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Determine the encoding of a stream of bytes according to the XML spec and return a Reader which
 * converts the byte stream into Unicode as it is read.
 *
 * @author digulla
 * @see java.io.Reader
 */
public class XMLInputStreamReader extends Reader {
  /** The constant "UTF-8" */
  public static final String ENCODING_UTF_8 = "UTF-8";
  /** The constant "ISO-8859-1", also called "Latin-1" */
  public static final String ENCODING_ISO_8859_1 = "ISO-8859-1";
  /** An alias for ENCODING_ISO_8859_1 */
  public static final String ENCODING_ISO_Latin_1 = ENCODING_ISO_8859_1;

  /**
   * A map to convert standard XML encodings into Java encodings.
   *
   * <p>The map is mutable; you can put your own encodings in here if you need them.
   */
  public static Map<String, String> ENCODING_MAP = new HashMap<String, String>();

  static {
    ENCODING_MAP.put("Utf-8", "UTF-8");
    ENCODING_MAP.put("utf-8", "UTF-8");
    ENCODING_MAP.put("Latin-1", "ISO-8859-1");
  }
  /**
   * A map to convert standard Java encodings into XML encodings.
   *
   * <p>The map is mutable; you can put your own encodings in here if you need them.
   */
  public static Map<String, String> JAVA_TO_XML_ENCODING_MAP = new HashMap<String, String>();

  static {
    JAVA_TO_XML_ENCODING_MAP.put("UnicodeBig", "UTF-16");
    JAVA_TO_XML_ENCODING_MAP.put("UnicodeLittle", "UTF-16");
  }

  private InputStream in;
  private String xmlEncoding;
  private String javaEncoding;
  private Reader delegate;

  public XMLInputStreamReader(InputStream in) {
    this.in = in;
  }

  public static final Pattern VERSION_PATTERN =
      Pattern.compile("\\bversion\\s*=\\s*[\"'][0-9.]+[\"']");

  /**
   * Determine the encoding of the stream.
   *
   * <p>It is safe to call this method more than once from a single thread.
   */
  public void determineEncoding() {
    if (xmlEncoding != null) return;

    String s = "";
    int encodingOffset = 0;

    byte[] header = new byte[1024];
    int headerLength;
    try {
      headerLength = in.read(header);
    } catch (IOException e) {
      XMLStringSource source = new XMLStringSource("");
      throw new XMLParseException("Error parsing XML declaration: " + e.getMessage(), e)
          .setSource(source, 0);
    }

    String encoding = ENCODING_UTF_8;
    int skip = 0;
    if (headerLength < 2) {
      // Do nothing
    } else if ((header[0] == (byte) 0xFE && header[1] == (byte) 0xFF)) {
      encoding = "UnicodeBig";
      skip = 2;
    } else if ((header[0] == (byte) 0xFF && header[1] == (byte) 0xFE)) {
      encoding = "UnicodeLittle";
      skip = 2;

      if (headerLength >= 4) {
        if (header[2] == 0 && header[3] == 0) {
          // UTF-32 Little Endian
          throw new XMLParseException("UTF 32 Little Endian not supported, yet. Patches welcome.");
        }
      }
    } else if (headerLength < 3) {
      // Do nothing
    } else if ((header[0] == (byte) 0xEF && header[1] == (byte) 0xBB) && header[2] == (byte) 0xBF) {
      encoding = ENCODING_UTF_8;
      skip = 3;
    } else if (headerLength < 4) {
      // Do nothing
    } else if ((header[0] == (byte) 0x00 && header[1] == (byte) 0x00)
        && header[2] == (byte) 0xFE
        && header[3] == (byte) 0xFF) {
      // UTF-32 Big Endian
      throw new XMLParseException("UTF 32 Big Endian not supported, yet. Patches welcome.");
    } else if (header[0] == '<' && header[1] == '?' && header[2] == 'x' && header[3] == 'm') {
      int pos = 4;
      for (; pos < headerLength; pos++) {
        int c = header[pos] & 0xFF;
        if (c == '>') break;

        if (Character.isLetter(c)) continue;
        if (Character.isDigit(c)) continue;
        if (Character.isWhitespace(c)) continue;
        if (c == '?' || c == '"' || c == '\'' || c == '=' || c == '-' || c == '_' || c == '.')
          continue;

        XMLStringSource source = null;
        try {
          source = new XMLStringSource(new String(header, ENCODING_ISO_8859_1));
        } catch (UnsupportedEncodingException e) {
          throw new XMLParseException(
              "Error parsing XML declaration: Unsupported encoding " + e.getMessage(), e); // @COBEX
        }
        throw new XMLParseException("Found illegal character in XML header: '" + (char) c + "'")
            .setSource(source, pos);
      }

      try {
        s = new String(header, 0, pos, ENCODING_UTF_8);
      } catch (UnsupportedEncodingException e) // @COBEX Note: this code will never be executed
      {
        throw new XMLParseException(
            "Error parsing XML declaration: Unsupported encoding " + e.getMessage(), e); // @COBEX
      }
      // System.out.println (s);

      Matcher matcher = VERSION_PATTERN.matcher(s);
      if (!matcher.find())
        throw new XMLParseException("Missing version in XML declaration")
            .setSource(new XMLStringSource(s), pos);

      String pattern = "encoding=";
      pos = s.indexOf(pattern);
      if (pos != -1) {
        pos = pos + pattern.length();
        int c = s.charAt(pos);
        if (c != '\'' && c != '"')
          throw new XMLParseException("Missing opening quote for encoding")
              .setSource(new XMLStringSource(s), pos);

        pos++;
        int pos2 = s.indexOf(c, pos);
        if (pos2 == -1)
          throw new XMLParseException("Missing closing quote for encoding")
              .setSource(new XMLStringSource(s), pos);

        encoding = s.substring(pos, pos2);
        encodingOffset = pos;
      }
    }

    // System.out.println ("encoding="+encoding);
    // FIXME Setting skip to 0 here seems to have no impact :( Why doesn't XMLParserTest.testBOM
    // fail??

    JoinedInputStream jin =
        new JoinedInputStream()
            .add(new ByteArrayInputStream(header, skip, headerLength - skip))
            .add(in);

    setXmlEncoding(encoding);
    try {
      delegate = new InputStreamReader(jin, javaEncoding);
    } catch (UnsupportedEncodingException e) {
      throw new XMLParseException(
              "Error parsing XML declaration: Unsupported encoding '" + javaEncoding + "'", e)
          .setSource(new XMLStringSource(s), encodingOffset);
    }
  }

  /** Return the encoding of the stream */
  public String getXmlEncoding() {
    determineEncoding();
    return xmlEncoding;
  }

  /**
   * If you know the encoding, you can override it here.
   *
   * <p>When you do that, the reader will ignore the encoding in the stream if there is one.
   */
  public void setXmlEncoding(String xmlEncoding) {
    this.xmlEncoding = xmlEncoding;
    javaEncoding = ENCODING_MAP.get(xmlEncoding);
    if (javaEncoding == null) javaEncoding = xmlEncoding;
    if (JAVA_TO_XML_ENCODING_MAP.containsKey(this.xmlEncoding))
      this.xmlEncoding = JAVA_TO_XML_ENCODING_MAP.get(this.xmlEncoding);
  }

  /** Get the Java name of the XML encoding of the stream. */
  public String getJavaEncoding() {
    determineEncoding();
    return javaEncoding;
  }

  @Override
  public void close() throws IOException {
    if (delegate == null) in.close();
    else delegate.close();
  }

  @Override
  public int read(char[] cbuf, int off, int len) throws IOException {
    determineEncoding();
    return delegate.read(cbuf, off, len);
  }
}
