/*
 * 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;

/**
 * This class allows to manipulate the XML encoding at the beginning of an XML document.
 *
 * @author DIGULAA
 */
public class XMLDeclaration extends ProcessingInstruction {
  private String versionSpace;
  private String version;
  private String versionEquals;
  private char versionQuote;
  private String encodingSpace;
  private String encoding;
  private String encodingEquals;
  private char encodingQuote;
  private String standaloneSpace;
  private boolean standalone;
  private String standaloneEquals;
  private char standaloneQuote;
  private boolean showStandaloneNo;
  private String postSpace;

  public XMLDeclaration(Token token) {
    super(token);
  }

  public XMLDeclaration(String version) {
    this(version, null, false);
  }

  public XMLDeclaration(String version, String encoding) {
    this(version, encoding, false);
  }

  public XMLDeclaration(String version, String encoding, boolean standalone) {
    super("xml", null);

    checkVersion(version);
    if (encoding != null && encoding.trim().length() == 0)
      throw new IllegalArgumentException("encoding is blank");

    this.versionSpace = " ";
    this.versionEquals = "=";
    this.versionQuote = '"';
    this.version = version;
    this.encodingSpace = " ";
    this.encodingEquals = "=";
    this.encodingQuote = '"';
    this.encoding = encoding;
    this.standaloneSpace = " ";
    this.standaloneEquals = "=";
    this.standaloneQuote = '"';
    this.standalone = standalone;
    this.postSpace = "";

    updateText();
  }

  /** @param version */
  protected void checkVersion(String version) {
    if (version == null || version.trim().length() == 0)
      throw new IllegalArgumentException("version is null or blank");
    if (!"1.0".equals(version) && !"1.1".equals(version))
      throw new IllegalArgumentException(
          "only versions '1.0' and '1.1' are supported: [" + version + "]");
  }

  public String getVersionSpace() {
    return versionSpace;
  }

  public XMLDeclaration setVersionSpace(String versionSpace) {
    checkSpace("version", versionSpace);
    this.versionSpace = versionSpace;
    updateText();
    return this;
  }

  protected void checkSpace(String name, String value) {
    if (value == null) throw new XMLParseException("space before " + name + " field can't be null");
    if (value.length() == 0)
      throw new XMLParseException("space before " + name + " field can't be empty");
    if (value.trim().length() != 0)
      throw new XMLParseException(
          "space before " + name + " field must contain only whitespace: [" + value + "]");
  }

  public String getVersion() {
    return version;
  }

  public XMLDeclaration setVersion(String version) {
    checkVersion(version);

    this.version = version;
    updateText();
    return this;
  }

  public String getVersionEquals() {
    return versionEquals;
  }

  public XMLDeclaration setVersionEquals(String versionEquals) {
    checkEquals("version", versionEquals);
    this.versionEquals = versionEquals;
    updateText();
    return this;
  }

  protected void checkEquals(String name, String value) {
    if (value == null)
      throw new XMLParseException("equal sign after " + name + " field can't be null");
    if (value.length() == 0)
      throw new XMLParseException("equal sign after " + name + " field can't be empty");
    if (!"=".equals(value.trim()))
      throw new XMLParseException(
          "equal sign after "
              + name
              + " field must only contain whitespace and a single '=': ["
              + value
              + "]");
  }

  public char getVersionQuote() {
    return versionQuote;
  }

  public XMLDeclaration setVersionQuote(char versionQuote) {
    checkQuote("version", versionQuote);
    this.versionQuote = versionQuote;
    updateText();
    return this;
  }

  private void checkQuote(String name, char value) {
    if (value != '"' && value != '\'')
      throw new XMLParseException(
          "The quote for " + name + " must be '\"' or '\\'': [" + value + "]");
  }

  public String getEncodingSpace() {
    return encodingSpace;
  }

  public XMLDeclaration setEncodingSpace(String encodingSpace) {
    checkSpace("encoding", encodingSpace);
    this.encodingSpace = encodingSpace;
    updateText();
    return this;
  }

  public String getEncoding() {
    return encoding;
  }

  public XMLDeclaration setEncoding(String encoding) {
    if (encoding != null && encoding.trim().length() == 0)
      throw new IllegalArgumentException("encoding is blank");

    this.encoding = encoding;
    updateText();
    return this;
  }

  public String getEncodingEquals() {
    return encodingEquals;
  }

  public XMLDeclaration setEncodingEquals(String encodingEquals) {
    checkEquals("encoding", encodingEquals);
    this.encodingEquals = encodingEquals;
    updateText();
    return this;
  }

  public char getEncodingQuote() {
    return encodingQuote;
  }

  public XMLDeclaration setEncodingQuote(char encodingQuote) {
    this.encodingQuote = encodingQuote;
    updateText();
    return this;
  }

  public String getStandaloneSpace() {
    return standaloneSpace;
  }

  public XMLDeclaration setStandaloneSpace(String standaloneSpace) {
    checkSpace("standalone", standaloneSpace);
    this.standaloneSpace = standaloneSpace;
    updateText();
    return this;
  }

  public boolean isStandalone() {
    return standalone;
  }

  public XMLDeclaration setStandalone(boolean standalone) {
    this.standalone = standalone;
    updateText();
    return this;
  }

  public String getStandaloneEquals() {
    return standaloneEquals;
  }

  public XMLDeclaration setStandaloneEquals(String standaloneEquals) {
    checkEquals("standalone", standaloneEquals);
    this.standaloneEquals = standaloneEquals;
    updateText();
    return this;
  }

  public char getStandaloneQuote() {
    return standaloneQuote;
  }

  public XMLDeclaration setStandaloneQuote(char standaloneQuote) {
    this.standaloneQuote = standaloneQuote;
    updateText();
    return this;
  }

  public boolean isShowStandaloneNo() {
    return showStandaloneNo;
  }

  public XMLDeclaration setShowStandaloneNo(boolean showStandaloneNo) {
    this.showStandaloneNo = showStandaloneNo;
    updateText();
    return this;
  }

  public String getPostSpace() {
    return postSpace;
  }

  public XMLDeclaration setPostSpace(String postSpace) {
    if (postSpace == null) postSpace = "";
    else if (postSpace.trim().length() != 0)
      throw new XMLParseException(
          "Space after the last field must not contain anything but whitespace");
    this.postSpace = postSpace;
    updateText();
    return this;
  }

  protected void updateText() {
    setText(buildText(version, encoding, standalone));
  }

  protected String buildText(String version, String encoding, boolean standalone) {
    StringBuilder buffer = new StringBuilder(32);

    buffer.append(versionSpace);
    buffer.append("version");
    buffer.append(versionEquals);
    buffer.append(versionQuote);
    buffer.append(version);
    buffer.append(versionQuote);

    // The XML spec demands an encoding field if standalone="yes"
    // But the XML test suite has a valid example which omits the encoding.
    String s = encoding;
    if (s != null) {
      buffer.append(encodingSpace);
      buffer.append("encoding");
      buffer.append(encodingEquals);
      buffer.append(encodingQuote);
      buffer.append(s);
      buffer.append(encodingQuote);
    }

    s = (standalone ? "yes" : (showStandaloneNo ? "no" : null));
    if (s != null) {
      buffer.append(standaloneSpace);
      buffer.append("standalone");
      buffer.append(standaloneEquals);
      buffer.append(standaloneQuote);
      buffer.append(s);
      buffer.append(standaloneQuote);
    }

    buffer.append(postSpace);

    return buffer.toString();
  }

  public static boolean isXMLDeclaration(ProcessingInstruction pi) {
    return "xml".equalsIgnoreCase(pi.getTarget());
  }

  public static XMLDeclaration parseXMLDeclaration(ProcessingInstruction pi) {
    XMLDeclaration decl = new XMLDeclaration("1.0");
    decl.parseXMLDeclaration(pi.getValue());
    return decl;
  }

  protected void parseXMLDeclaration(String text) {
    XMLSource source = new XMLStringSource(text);

    if (!text.startsWith("<?xml")) throw new XMLParseException("Expected '<?xml'", source, 0);

    int pos = 5;
    int end = text.length() - 2;
    if (!text.endsWith("?>")) throw new XMLParseException("Expected '?>'", source, end);

    version = null;
    encoding = null;
    standalone = false;
    boolean sawStandalone = false;
    String attrName;

    while (pos < end) {
      if (!Character.isWhitespace(text.charAt(pos)))
        throw new XMLParseException(
            "Expecting whitespace between attributes of XML declaration", source, pos);

      int spaceStart = pos;
      while (pos < end && Character.isWhitespace(text.charAt(pos))) pos++;

      if (pos == end) {
        postSpace = text.substring(spaceStart, pos);
        break;
      }

      char startChar = text.charAt(pos);
      if (startChar == 'v') {
        expect(source, pos, attrName = "version");
        if (version != null)
          throw new XMLParseException("Found a second version attribute", source, pos);
        versionSpace = text.substring(spaceStart, pos);
        pos += 7;
      } else if (startChar == 'e') {
        expect(source, pos, attrName = "encoding");
        if (encoding != null)
          throw new XMLParseException("Found a second encoding attribute", source, pos);
        if (version == null)
          throw new XMLParseException("Version must be before encoding", source, pos);
        encodingSpace = text.substring(spaceStart, pos);
        pos += 8;
      } else if (startChar == 's') {
        expect(source, pos, attrName = "standalone");
        if (sawStandalone)
          throw new XMLParseException("Found a second standalone attribute", source, pos);
        sawStandalone = true;
        standaloneSpace = text.substring(spaceStart, pos);
        pos += 10;
      } else
        throw new XMLParseException("Expected 'version', 'encoding' or 'standalone'", source, pos);

      spaceStart = pos;
      while (pos < end && Character.isWhitespace(text.charAt(pos))) pos++;

      if (pos == end || text.charAt(pos) != '=')
        throw new XMLParseException("Expected '=' after " + attrName, source, pos);
      pos++;

      while (pos < end && Character.isWhitespace(text.charAt(pos))) pos++;

      if (pos == end) throw new XMLParseException("Expected value for " + attrName, source, pos);

      if (startChar == 'v') {
        versionEquals = text.substring(spaceStart, pos);
      } else if (startChar == 'e') {
        encodingEquals = text.substring(spaceStart, pos);
      } else if (startChar == 's') {
        standaloneEquals = text.substring(spaceStart, pos);
      }

      char quoteChar = text.charAt(pos);
      if (quoteChar != '"' && quoteChar != '\'')
        throw new XMLParseException(
            "Expected single or double quotes around value of "
                + attrName
                + " but found '"
                + quoteChar
                + "' ("
                + Integer.toString(quoteChar, 16)
                + ")",
            source,
            pos);

      pos++;
      char otherQuoteChar = (quoteChar == '"' ? '\'' : '"');
      int valueStart = pos;
      while (pos < end) {
        char c = text.charAt(pos);
        if (c == quoteChar) break;

        if (c == otherQuoteChar)
          throw new XMLParseException(
              "Quote mismatch: Expected [" + quoteChar + "], found [" + c + "]", source, pos);
        pos++;
      }

      if (pos == end)
        throw new XMLParseException(
            "Missing closing quote after value for " + attrName, source, pos);

      String value = text.substring(valueStart, pos);
      pos++;

      if (value.trim().length() == 0)
        throw new XMLParseException("Value for " + attrName + " is empty", source, valueStart - 1);

      if (startChar == 'v') {
        try {
          checkVersion(value);
        } catch (IllegalArgumentException e) {
          throw new XMLParseException(e.getMessage(), e).setSource(source, valueStart - 1);
        }
        version = value;
        versionQuote = quoteChar;
      } else if (startChar == 'e') {
        encoding = value;
        encodingQuote = quoteChar;
      } else if (startChar == 's') {
        if ("no".equals(value) || "yes".equals(value)) standalone = "yes".equals(value);
        else
          throw new XMLParseException(
              "Allowed values for standalone are 'yes' and 'no', found '" + value + "'",
              source,
              pos);

        standaloneQuote = quoteChar;
        showStandaloneNo = !standalone;
      }
    }

    if (version == null) throw new XMLParseException("Missing version attribute", source, 0);

    updateText();
  }

  private void expect(XMLSource source, int pos, String expected) {
    int end = Math.min(pos + expected.length(), source.length());
    String s = source.substring(pos, end);
    if (!expected.equals(s))
      throw new XMLParseException("Expected '" + expected + "' but found '" + s + "'", source, pos);
  }

  @Override
  public XMLDeclaration createClone() {
    return new XMLDeclaration(version);
  }

  @Override
  public XMLDeclaration copy(Node orig) {
    super.copy(orig);

    XMLDeclaration other = (XMLDeclaration) orig;

    this.encoding = other.encoding;
    this.encodingEquals = other.encodingEquals;
    this.encodingQuote = other.encodingQuote;
    this.encodingSpace = other.encodingSpace;
    this.postSpace = other.postSpace;
    this.showStandaloneNo = other.showStandaloneNo;
    this.standalone = other.standalone;
    this.standaloneEquals = other.standaloneEquals;
    this.standaloneQuote = other.standaloneQuote;
    this.standaloneSpace = other.standaloneSpace;
    this.version = other.version;
    this.versionEquals = other.versionEquals;
    this.versionQuote = other.versionQuote;
    this.versionSpace = other.versionSpace;

    return this;
  }

  @Override
  public XMLDeclaration copy() {
    return (XMLDeclaration) super.copy();
  }
}
