/*
  Copyright (C) 2006 Helge Hess

  This file is part of JOPE.

  JOPE is free software; you can redistribute it and/or modify it under
  the terms of the GNU Lesser General Public License as published by the
  Free Software Foundation; either version 2, or (at your option) any
  later version.

  JOPE is distributed in the hope that it will be useful, but WITHOUT ANY
  WARRANTY; without even the implied warranty of MERCHANTABILITY or
  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
  License for more details.

  You should have received a copy of the GNU Lesser General Public
  License along with JOPE; see the file COPYING.  If not, write to the
  Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA
  02111-1307, USA.
*/

package org.opengroupware.jope.foundation;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/*
 * NSPropertyListParser
 * 
 * Error Handling: call parse(). If it returns 'null', retrieve the
 *                 lastException() from the parser for details.
 * 
 * THREAD: this class is not threadsafe!
 * 
 * Note: this is a straight port of the ObjC parser and therefore somewhat
 *       clumsy.
 */

// TODO: finish me

public class NSPropertyListParser extends NSObject {

  protected static Log plistLog = LogFactory.getLog("NSPropertyListParser");
  protected Log log; /* redefined by WODParser */
  
  protected NSPropertyListSyntaxException lastException;
  protected int     idx    = -1;
  protected int     len    = -1;
  protected char[]  buffer;
  protected boolean isDebugOn;
  
  public NSPropertyListParser() {
    this.log = plistLog;
    this.isDebugOn = this.log.isDebugEnabled();
  }
  
  /* top-level parsing */
  
  public Object parse() {
    if (this.isDebugOn) this.log.debug("start parsing ...");
    
    Object o = this._parseProperty();
    this.resetTransient(); /* cleanup unnecessary state */
    
    if (this.isDebugOn) {
      if (o != null)
        this.log.debug("finished parsing: " + o.getClass());
      else
        this.log.debug("parsing failed: " + this.lastException);
    }
    
    return o;
  }
  
  /* some convenience methods */
  
  public Object parse(String _s) {
    this.setString(_s);
    return this.parse();
  }
  public Object parse(char[] _buf) {
    this.setCharBuffer(_buf);
    return this.parse();
  }
  
  public Object parse(byte[] _buf) {
    if (_buf == null)
      return null;
    
    // TODO: check prefix for encoding
    try {
      return this.parse(new String(_buf, "utf8"));
    }
    catch (UnsupportedEncodingException e) {
      this.log.error("failed to transform byte array to UTF-8", e);
      return null;
    }
  }

  public Object parse(InputStream _in) {
    if (_in == null)
      return null;
    
    return this.parse(loadContentFromStream(_in));
  }
  
  protected static final Class[] urlTypes = {
    String.class, byte[].class, InputStream.class
  };
  
  public Object parse(URL _url) {
    if (_url == null)
      return null;
    
    if (this.log.isDebugEnabled())
      this.log.debug("parse URL: " + _url);
    
    Object o = null;
    try {
      o = _url.getContent(urlTypes);
    }
    catch (IOException e) {
      this.log.error("failed to read from URL: " + _url, e);
    }
    if (o == null)
      return null;
    
    if (o instanceof String)
      return this.parse((String)o);
    if (o instanceof byte[]) // TODO: check charset header?
      return this.parse((byte[])o);
    if (o instanceof InputStream) // TODO: check charset header?
      return this.parse((InputStream)o);
    
    this.log.error("don't know how to deal with URL content: " + o.getClass());
    return null;
  }
  
  
  /* convenience methods */
  
  public static Object parse(Class _baseClass, String _resourceName) {
    if (_baseClass == null || _resourceName == null)
      return null;
    
    if (_resourceName.indexOf('.') == -1) _resourceName += ".plist";
    URL url = _baseClass.getResource(_resourceName);
    if (url == null) {
      plistLog.error("did not find resource in class " + _baseClass + " : " + 
                      _resourceName);
      return null;
    }
    
    NSPropertyListParser parser = new NSPropertyListParser();
    Object plist = parser.parse(url);
    
    if (plist == null) {
      plistLog.error("could not load plist resource: " + url, 
                     parser.lastException());
      return null;
    }
    return plist;
  }
  
  /* setting input */

  public void setCharBuffer(char[] _buffer) {
    this.reset();
    this.buffer        = _buffer;
    this.idx           = 0;
    this.len           = _buffer.length;
    this.lastException = null;  
  }
  
  public void setString(String _str) {
    this.setCharBuffer(_str.toCharArray());
  }
  
  public void resetTransient() {
    this.buffer = null;
    this.idx    = -1;
    this.len    = -1;
  }
  public void reset() {
    this.resetTransient();
    this.lastException = null;
  }
  
  /* error handling */
  
  public NSPropertyListSyntaxException lastException() {
    return this.lastException;
  }
  public void resetLastException() {
    this.lastException = null;
  }
  
  protected void addException(String _error) {
    // TODO: keep old exceptions?
    this.lastException =
      new NSPropertyListSyntaxException(_error, this.lastException);
  }

  /* char classification */
  
  protected boolean _isBreakChar(char _c) {
    switch (_c) {
      case ' ': case '\t': case '\n': case '\r':
      case '=':  case ';':  case ',':
      case '{': case '(':  case '"':  case '<':
      case '.': case ':':
      case ')': case '}':
        return true;

      default:
        return false;
    }    
  }
  
  protected boolean _isIdChar(char _c) {
    return (this._isBreakChar(_c) && (_c != '.')) ? false : true;
  }
  
  protected static int _valueOfHexChar(char _c) {
    switch (_c) {
      case '0': case '1': case '2': case '3': case '4':
      case '5': case '6': case '7': case '8': case '9':
        return (_c - 48); // 0-9 (ascii-char)'0' - 48 => (int)0
        
      case 'A': case 'B': case 'C':
      case 'D': case 'E': case 'F':
        return (_c - 55); // A-F, A=10..F=15, 'A'=65..'F'=70
        
      case 'a': case 'b': case 'c':
      case 'd': case 'e': case 'f':
        return (_c - 87); // a-f, a=10..F=15, 'a'=97..'f'=102

      default:
        return -1;
    }    
  }
  protected static boolean _isHexDigit(char _c) {
    switch (_c) {
      case '0': case '1': case '2': case '3': case '4':
      case '5': case '6': case '7': case '8': case '9':
      case 'A': case 'B': case 'C':
      case 'D': case 'E': case 'F':
      case 'a': case 'b': case 'c':
      case 'd': case 'e': case 'f':
        return true;

      default:
        return false;
    }    
  }
  
  /* parsing */

  protected boolean _skipComments() {
    int     pos = this.idx;
    boolean lookAgain;
    
    if (this.isDebugOn)
      this.log.debug("_skipComments(): pos=" + pos + ", len=" + this.len);
    
    if (pos >= this.len)
      return false;
    
    do { /* until all comments are filtered .. */
      lookAgain = false;
      
      if (this.buffer[pos] == '/' && (pos + 1 < this.len)) {
        
        if (this.buffer[pos + 1] == '/') { /* singleline comment */
          pos += 2; /* skip // */
          
          /* search for newline */
          while ((pos < this.len) && (this.buffer[pos] != '\n'))
            pos++;
          
          if ((pos < this.len) && (this.buffer[pos] == '\n')) {
            pos++; /* skip newline, otherwise we got EOF */
            lookAgain = true;
          }
        }
        else if (this.buffer[pos + 1] == '*') { /* multiline comment */
          boolean commentIsClosed = false;
          
          pos += 2; // skip '/*'
          
          while (pos + 1 < this.len) { // search for '*/'
            if (this.buffer[pos] == '*' && this.buffer[pos + 1] == '/') {
              pos += 2; // skip '*/'
              commentIsClosed = true;
              break;
            }
            
            pos++;
          }

          if (!commentIsClosed) {
            /* EOF found, comment wasn't closed */
            this.addException("comment was not closed (expected '*/')");
            return false;
          }
        }
      }
      else if (Character.isWhitespace(this.buffer[pos])) {
        pos++;
        lookAgain = true;
      }
    }
    while (lookAgain && (pos < this.len));
    
    this.idx = pos;
    
    return (pos < this.len) ? true : false;
  }
  
  protected String _parseIdentifier() {
    if (!this._skipComments()) {
      /* EOF reached during comment-skipping */
      this.addException("did not find an id (expected 'a-zA-Z0-0')");
      return null;
    }

    int pos      = this.idx;
    int ilen     = 0;
    int startPos = pos;
    
    if (this.isDebugOn)
      this.log.debug("_parseId(): pos=" + pos + ", len=" + this.len);
    
    /* loop until break char */
    while ((pos < this.len) && _isIdChar(this.buffer[pos])) {
      pos++;
      ilen++;
    }
    
    if (this.isDebugOn)
      this.log.debug("  _parseId(): pos=" + pos + ", len=" + ilen);
    
    if (ilen == 0) { /* wasn't a string .. */
      this.addException("did not find an id (expected 'a-zA-Z0-0')");
      return null;
    }
    
    this.idx = pos;
    return new String(this.buffer, startPos, ilen);
  }
  
  protected String _parseKeyPath() {
    String component = null;

    if (!this._skipComments()) {
      /* EOF reached during comment-skipping */
      this.addException("did not find an keypath (expected id)");
      return null;
    }
    
    component = this._parseIdentifier();
    if (component == null) {
      this.addException("did not find an keypath (expected id)");
      return null;
    }
    
    /* single id-keypath */
    if (this.idx >= this.len) // EOF
      return component;
    if (this.buffer[this.idx] != '.')
      return component;
    
    StringBuffer keypath = new StringBuffer(64);
    keypath.append(component);
    
    while ((this.buffer[this.idx] == '.') && (component != null)) {
      this.idx += 1; /* skip '.' */
      keypath.append('.');
      
      component = this._parseIdentifier();
      if (component == null) {
        this.addException("expected component after '.' in keypath!");
        break; // TODO: should we return null?
      }
      
      keypath.append(component);
    }
    
    return keypath.toString();
  }
  
  protected String _parseQString() {
    /* skip comments and spaces */
    if (!this._skipComments()) {
      /* EOF reached during comment-skipping */
      this.addException("did not find a quoted string (expected '\"')");
      return null;
    }
    
    if (this.buffer[this.idx] != '"') { /* it's not a quoted string */
      this.addException("did not find a quoted string (expected '\"')");
      return null;
    }
    
    /* a quoted string */
    int pos      = this.idx + 1;  /* skip quote */
    int ilen     = 0;
    int startPos = pos;
    boolean containsEscaped = false;
    
    /* loop until closing quote */
    while ((this.buffer[pos] != '"') && (pos < this.len)) {
      if (this.buffer[pos] == '\\') {
        containsEscaped = true;
        pos++; /* skip following char */
        if (pos == this.len) {
          this.addException("escape in quoted string not finished!");
          return null;
        }
      }
      pos++;
      ilen++;
    }
    
    if (pos == this.len) { /* syntax error, quote not closed */
      this.idx = pos;
      this.addException("quoted string not closed (expected '\"')");
      return null;
    }
    
    pos++;          /* skip closing quote */
    this.idx = pos; /* store pointer */
    pos = 0;
    
    if (ilen == 0) /* empty string */
      return "";
    
    if (containsEscaped) {
      // TODO: implement unescaping in quoted strings
      this.log.error("unescaping not implemented!");
    }
    
    return new String(this.buffer, startPos, ilen);
  }
  
  protected byte[] _parseData() {
    // TODO: implement me
    this.log.error("data plist parsing not implemented!");
    return null;
  }
  
  protected Map<Object,Object> _parseDict() {
    if (this.isDebugOn)
      this.log.debug("_parseDict(): pos=" + this.idx + ", len=" + this.len);

    /* skip comments and spaces */
    if (!this._skipComments()) {
      /* EOF reached during comment-skipping */
      this.addException("did not find dictionary (expected '{')");
      return null;
    }
    
    if (this.buffer[this.idx] != '{') { /* it's not a dict that follows */
      this.addException("did not find dictionary (expected '{')");
      return null;
    }
    
    this.idx += 1; /* skip '{' */
    
    if (!this._skipComments()) {
      this.addException("dictionary was not closed (expected '}')");
      return null; /* EOF */
    }
    
    if (this.buffer[this.idx] == '}') { /* an empty dictionary */
      this.idx += 1; /* skip the '}' */
      return new HashMap<Object, Object>(0); // TODO: add an empty-map obj?
    }
    
    Map<Object, Object> result = new HashMap<Object, Object>(16);
    boolean didFail = false;
    
    do {
      if (!this._skipComments()) {
        this.addException("dictionary was not closed (expected '}')");
        didFail = true;
        break; /* unexpected EOF */
      }
      
      if (this.buffer[this.idx] == '}') { /* dictionary closed */
        this.idx += 1; /* skip the '}' */
        break;
      }
      
      /* read key property or identifier */
      Object key = this._parseProperty();
      if (key == null) { /* syntax error */
        if (this.lastException == null)
          this.addException("got nil-key in dictionary ..");
        didFail = true;
        break;
      }
      
      /* The following parses:  (comment|space)* '=' (comment|space)* */
      if (!this._skipComments()) {
        this.addException("expected '=' after key in dictionary");
        didFail = true;
        break; /* unexpected EOF */
      }
      /* now we need a '=' assignment */
      if (this.buffer[this.idx] != '=') {
        this.addException("expected '=' after key in dictionary");
        didFail = true;
        break;
      }
      this.idx += 1; /* skip '=' */
      if (!this._skipComments()) {
        this.addException("expected value after key '=' in dictionary");
        didFail = true;
        break; /* unexpected EOF */
      }
      
      /* read value property */
      Object value = this._parseProperty();
      if (this.lastException != null) {
        didFail = true;
        break;
      }
      
      result.put(key, value);
      
      /* read trailing ';' if available */
      if (!this._skipComments()) {
        this.addException("dictionary was not closed (expected '}')");
        didFail = true;
        break; /* unexpected EOF */
      }
      if (this.buffer[this.idx] == ';')
        this.idx += 1; /* skip ';' */
      else { /* no ';' at end of pair, only allowed at end of dictionary */
        if (this.buffer[this.idx] != '}') { /* dictionary wasn't closed */
          this.addException("key-value pair without ';' at the end");
          didFail = true;
          break;
        }
      }
    }
    while ((this.idx < this.len) && (result != null) && !didFail);
    
    return didFail ? null : result;
  }
  
  protected List<Object> _parseArray() {
    if (this.isDebugOn)
      this.log.debug("_parseAry(): pos=" + this.idx + ", len=" + this.len);
    
    if (!this._skipComments()) {
      /* EOF reached during comment-skipping */
      this.addException("did not find array (expected '(')");
      return null;
    }
    
    if (this.buffer[this.idx] != '(') { /* it's not an array that follows */
      this.addException("did not find array (expected '(')");
      return null;
    }
    
    this.idx += 1; /* skip '(' */
    
    if (!this._skipComments()) {
      this.addException("array was not closed (expected '}')");
      return null; /* EOF */
    }
    
    if (this.buffer[this.idx] == ')') { /* an empty array */
      this.idx += 1; /* skip the ')' */
      return new ArrayList<Object>(0); // TODO: add an empty-map obj?
    }
    
    List<Object> result = new ArrayList<Object>(16);
    
    do {
      Object element = this._parseProperty();
      if (element == null) {
        this.addException("expected element in array at: " + this.idx);
        result = null;
        break;
      }
      
      result.add(element);
      
      if (!this._skipComments()) {
        this.addException("array was not closed (expected ')' or ',')");
        result = null;
        break;
      }
      
      if (this.buffer[this.idx] == ')') { /* closed array */
        this.idx += 1; /* skip ')' */
        break;
      }
      
      if (this.buffer[this.idx] != ',') { /* (no) next element */
        this.addException("expected ')' or ',' after array element");
        result = null;
        break;
      }
      
      this.idx += 1; /* skip ',' */
      if (!this._skipComments()) {
        this.addException("array was not closed (expected ')')");
        result = null;
        break;
      }
      
      if (this.buffer[this.idx] == ')') { /* closed array, like this '(1,2,)' */
        this.idx += 1; /* skip ')' */
        break;
      }
    }
    while ((this.idx < this.len) && (result != null));
    
    return result;
  }
  
  protected static Number _parseDigitPath(String _digitPath) {
    // TODO: weird function name?
    if (_digitPath == null)
      return null;
    
    if (_digitPath.indexOf('.') == -1)
      return new Integer(_digitPath);
    
    return new Double(_digitPath);
  }
  
  protected static boolean _ucIsEqual(char[] _buf, int _pos, String _s) {
    int len = _s.length();
    if (_buf.length <= _pos + len)
      return false;
    for (int i = 0; i < len; i++) {
      if (_buf[_pos + i] != _s.charAt(i))
        return false;
    }
    return true;
  }

  protected Object _parseProperty() {
    boolean valueProperty = true;
    Object  result = null;
    
    if (this.isDebugOn) this.log.debug("parse property at: " + this.idx);
    
    if (!this._skipComments())
      return null; /* EOF */
    
    char c = this.buffer[this.idx];
    switch (c) {
      case '"': /* quoted string */
        result = this._parseQString();
        break;
        
      case '{': /* dictionary */
        result = this._parseDict();
        break;
        
      case '(': /* array */
        result = this._parseArray();
        break;
        
      case '<': /* data */
        result = this._parseData();
        break;
        
      default:
        if (Character.isDigit(c) || (c == '-')) {
          String digitPath = this._parseKeyPath();
          result = _parseDigitPath(digitPath);
          valueProperty = true;
        }
        else if (_isIdChar(this.buffer[this.idx])) {
          valueProperty = false;
          
          if (c == 'Y' || c == 'N' || c == 't' || c == 'f' || c == 'n') {
            // Note: we do not allow a space behind a const, eg '= true ;'
            /* parse YES and NO, true and false */
            if (_ucIsEqual(this.buffer, this.idx, "YES") && 
                _isBreakChar(this.buffer[this.idx + 3])) {
              result = Boolean.TRUE;
              valueProperty = true;
              this.idx += 3;
            }
            else if (_ucIsEqual(this.buffer, this.idx, "NO") && 
                     _isBreakChar(this.buffer[this.idx + 2])) {
              result = Boolean.FALSE;
              valueProperty = true;
              this.idx += 2;
            }
            else if (_ucIsEqual(this.buffer, this.idx, "true") && 
                     _isBreakChar(this.buffer[this.idx + 4])) {
              result = Boolean.TRUE;
              valueProperty = true;
              this.idx += 4;
            }
            else if (_ucIsEqual(this.buffer, this.idx, "false") && 
                     _isBreakChar(this.buffer[this.idx + 5])) {
              result = Boolean.FALSE;
              valueProperty = true;
              this.idx += 5;
            }
            else if (_ucIsEqual(this.buffer, this.idx, "null") && 
                _isBreakChar(this.buffer[this.idx + 4])) {
              result = null;
              valueProperty = true;
              this.idx += 4;
            }
            else if (_ucIsEqual(this.buffer, this.idx, "nil") && 
                     _isBreakChar(this.buffer[this.idx + 3])) {
              result = null;
              valueProperty = true;
              this.idx += 3;
            }
          }
          
          if (!valueProperty) /* this means: did not match a constant yet */
            // TODO: should be renamed "parseUnquotedString"? This is a
            //       leftover from the .wod parser
            result = this._parseKeyPath();
        }
        else {
          this.addException("invalid char");
        }
        break;
    }
    
    if (this.lastException != null) {
      if (this.isDebugOn) {
        this.log.debug("parsing property failed at " + this.idx + ": " +
                       this.lastException);
      }
      return null;
    }
    
    if (result == null)
      this.addException("error in property value");
    
    if (this.isDebugOn) {
      this.log.debug("finished parsing property at " + this.idx + ": " +
                     result);
    }
    return result;
  }
  
  /* helper methods */
  
  public static byte[] loadContentFromStream(InputStream _in) {
    if (_in == null) return null;
    
    BufferedInputStream in = new BufferedInputStream(_in);
    
    // TODO: there must be a smarter way to do this ;-)
    byte[] results = new byte[0];
    try {
      int    didRead;
      byte[] buf = new byte[4096];
      
      while ((didRead = in.read(buf)) > 0) {
        byte[] nre = new byte[results.length + didRead];
        System.arraycopy(results, 0 /* start of source */,
                         nre,     0 /* start in dest   */,
                         results.length);
        System.arraycopy(buf, 0              /* start of source */,
                         nre, results.length /* start in dest   */,
                         didRead);
        
        results = nre;
      }
    }
    catch (IOException e) {
      System.err.println("failed to read data from stream: " + _in + ": " + e);
      return null;
    }
    
    return results;
  }
  
}
