package nxm.sys.lib;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;

import nxm.sys.inc.InternalUseOnly;
import nxm.sys.inc.ListFile;
import nxm.sys.inc.MidasReference;
import nxm.sys.inc.Tablizable;

/** A class to handle ESRI dbf (dBase III) files, which has Shapefile attribute format. <br>
 *  <br>
 *  File format:
 *  <pre>
 *     Offset | Length |   Type   | Description
 *    --------+--------+----------+-------------------------------------
 *          0 |      1 | Int8     | File code (version information).
 *    --------+--------+----------+-------------------------------------
 *          1 |      3 | Int8[3]  | Date of last update as three binary
 *            |        |          | values (YY, MM, DD).
 *    --------+--------+----------+-------------------------------------
 *          4 |      4 | Int32    | Number of records.
 *    --------+--------+----------+-------------------------------------
 *          8 |      2 | Int16    | Number of bytes in header.
 *    --------+--------+----------+-------------------------------------
 *         10 |      2 | Int16    | Number of bytes in a record.
 *    --------+--------+----------+-------------------------------------
 *         12 |     30 | n/a      | Reserved (must be 0).
 *            |        |          | (Some versions make limited use of
 *            |        |          | these fields, others ignore them.)
 *    --------+--------+----------+-------------------------------------
 *         32 |   32*n | Field[n] | Field descriptors (see below).
 *    --------+--------+----------+-------------------------------------
 *    32*(n+1)|      1 | Int8     | End of header (0x0D).
 *    --------+--------+----------+-------------------------------------
 *  </pre>
 *  Field Descriptor:
 *  <pre>
 *     Offset | Length |   Type   | Description
 *    --------+--------+----------+-------------------------------------
 *          0 |     11 | ascii[11]| Field name (zero-filled).
 *    --------+--------+----------+-------------------------------------
 *         11 |      1 | ascii[1] | Field type (see below).
 *    --------+--------+----------+-------------------------------------
 *         12 |      4 | n/a      | Reserved.
 *    --------+--------+----------+-------------------------------------
 *         16 |      1 | UInt8    | Field length (max is 0xFE).
 *    --------+--------+----------+-------------------------------------
 *         17 |      1 | UInt8    | Number digits to the right of the
 *            |        |          | decimal point.
 *    --------+--------+----------+-------------------------------------
 *         18 |     14 | n/a      | Reserved (must be 0).
 *            |        |          | (Some versions make limited use of
 *            |        |          | these fields, others ignore them.)
 *    --------+--------+----------+-------------------------------------
 *  </pre>
 *  Record:
 *  <pre>
 *     Offset | Length |   Type   | Description
 *    --------+--------+----------+-------------------------------------
 *          0 |      1 | ascii[1] | Deletion indicator:
 *            |        |          |   ' ' = Valid
 *            |        |          |   '*' = Deleted
 *    --------+--------+----------+-------------------------------------
 *          1 |    N-1 | n/a      | Record content.
 *    --------+--------+----------+-------------------------------------
 *  </pre>
 *  Field Types:
 *  <pre>
 *     Type |   Name    | Description
 *    ------+-----------+-----------------------------------------------
 *      C   | Character | ASCII character string padded with spaces.
 *    ------+-----------+-----------------------------------------------
 *      D   | Date      | Date in "YYYYMMDD" string form.
 *    ------+-----------+-----------------------------------------------
 *      F   | Float     | Numeric data (effectively the same as 'N').
 *    ------+-----------+-----------------------------------------------
 *      L   | Logical   | Boolean value:
 *          |           |   'T'/'t'/'Y'/'y'   = True   ('T' preferred)
 *          |           |   'F'/'f'/'N'/'n'   = False  ('F' preferred)
 *          |           |   '?'               = Unknown
 *          |           | (Some systems permit a ' ' as an alternative
 *          |           | to '?'.)
 *    ------+-----------+-----------------------------------------------
 *      M   | Memo      | ASCII string (10 digit code) indicating a
 *          |           | block in a .dbt file. (This has limited use.)
 *    ------+-----------+-----------------------------------------------
 *      N   | Numeric   | Numeric data in string form, left-padded with
 *          |           | spaces ('0'-'9' + '.' + '-').
 *    ------+-----------+-----------------------------------------------
 *  </pre>
 *
 *  @author Jeff Schoen (1st Ed.)
 *  @author J. Czechowski (2nd Ed.)
 */
public class DbfFile extends BaseFile implements ListFile {

  /** buffer size */
  public static final int BUFSZ = 32;

  private   int         fileCode;
  private   int         fileLength;
  private   int         dataStart;
  private   int         recordSize;
  private   List<Field> fields;


  /** Creates a new instance. */
  public DbfFile () { }

  /** Creates a new instance.
   *  @param ref      The Midas reference.
   *  @param filename The file name.
   *  @deprecated since NeXtMidas 3.5.0 use {@link #DbfFile(MidasReference, Object)}
   */
  @Deprecated
  @SuppressWarnings("OverridableMethodCallInConstructor")
  public DbfFile (Object ref, Object filename) {
    if (ref instanceof MidasReference) {
      init((MidasReference) ref, filename);
    } else {
      init(Convert.ref2Midas(ref), filename);
    }
  }

  /** Creates a new instance.
   *  @param ref      The Midas reference.
   *  @param filename The file name.
   *  @since NeXtMidas 3.5.0
   */
  @SuppressWarnings("OverridableMethodCallInConstructor")
  public DbfFile (MidasReference ref, Object filename) {
    init(ref,filename);
  }

  @Override public       double  getNumberOfRows   () { return fileLength; }
  @Override public       double  getSize           () { return fileLength; }
  @Override public       boolean getProtected      () { return false; }
  @Override public       int     getRecordDefCount () { return fields.size(); }
  @Override public final void    removeData (double o) { removeData(o, 1L); }
  @Override public       int     seek       (double o) { return super.seek(toByteOffset(o)); }

  @Override
  public String toString () {
    return "DbfFile "+((io!=null)? io.getTypeString() : "")+" Resource";
  }

  @Override
  public String listHeader () {
    StringBuilder str = new StringBuilder(256);

    str.append("DbfFile     :  ").append(getURL()     ).append('\n');
    str.append("File Code   :  ").append(fileCode     ).append('\n');
    str.append("Record Size :  ").append(recordSize   ).append('\n');
    str.append("Data Start  :  ").append(dataStart    ).append('\n');
    str.append("Size        :  ").append(getSize()    ).append('\n');

    if (fields != null) {
      str.append("Fields      :  ").append(fields.size()).append('\n');
      fields.forEach((f) -> str.append("  ").append(f).append('\n'));
    }
    else {
      str.append("Fields      :  n/a\n");
    }
    return str.toString();
  }

  @Override
  public String listElements (double start, int elements, String format, int flags) {
    return getDataTable(start)+"\n";
  }

  @Override
  public int listElementsPerLine (int lineWidth, String form, int flags) {
    return 1;
  }

  /** Gets all of the elements from a specific column as an array. This is a legacy method;
   *  in most cases it is more efficient to just iterate through the rows of interest using
   *  {@link #getDataTable(double)}.
   *  @param elem The name of the column of interest.
   *  @return The values (in string form).
   */
  @InternalUseOnly("Only used by LayerShape") // Marked since NeXtMidas 3.9.0
  public String[] getElementArray (String elem) {
    return getElementArray(elem, 0, fileLength - 1);
  }

  /** Gets all of the elements from a specific column as an array. This is a legacy method;
   *  in most cases it is more efficient to just iterate through the rows of interest using
   *  {@link #getDataTable(double)}.
   *  @param elem The name of the column of interest.
   *  @param first The first row of interest (inclusive).
   *  @param last  The last row of interest (inclusive).
   *  @return The values (in string form).
   */
  @InternalUseOnly("Only used by LayerShape") // Marked since NeXtMidas 3.9.0
  public String[] getElementArray (String elem, int first, int last) {
    String[] vals = new String[last - first + 1];
    int n = 0;
    for (int i = first; i <= last; i++) {
      Table t = getDataTable(i);
      vals[n++] = t.getS(elem);
    }
    return vals;
  }

  /** Returns "unknown".
   *  @return "unknown"
   *  @deprecated Since NeXtMidas 3.9.0 -- This does nothing of value.
   */
  @Deprecated
  public String getTypeString () {
    return "unknown";
  }

  @Override
  public boolean open () {
    if (!super.open()) return false;

    if ((flags & INPUT) != 0) {
      byte[] hb = new byte[1024];
      io.read(hb,0,BUFSZ,0);
      fileCode   = hb[0];
      fileLength = Convert.unpackL(hb, 4,EEEI);
      dataStart  = Convert.unpackI(hb, 8,EEEI);
      recordSize = Convert.unpackI(hb,10,EEEI);

      int numFields  = (dataStart-BUFSZ-1)/BUFSZ;
      int start      = 1; // +1 for end-of-header byte

      fields = new ArrayList<>(numFields);

      for (int i = 0; i < numFields; i++) {
        Field f = new Field(this, start);
        start += f.getLength();
        fields.add(f);
      }
    }
    else {
      fileCode   = 0x03;
      fileLength = 0;
      dataStart  = BUFSZ+1;
      recordSize = 0;
      fields     = new ArrayList<>(8);
      update();
    }

    return true;
  }

  @Override
  public void update () {
    byte[] hb  = new byte[BUFSZ];
    byte[] eoh = { 0x0D };
    Time   now = Time.currentTime();

    int newDataStart = (fields.size() * BUFSZ) + BUFSZ + 1;
    if (newDataStart < dataStart) removeBytes(BUFSZ, newDataStart-dataStart);
    if (newDataStart > dataStart) insertBytes(BUFSZ, newDataStart-dataStart);

    dataStart  = newDataStart;
    recordSize = 1;

    // Write the fields first (this comes later, but we need this information
    // to write the main header)...
    int off = BUFSZ;
    for (Field f : fields) {
      io.write(f.buf, 0, f.buf.length, off);
      off        += f.buf.length;
      recordSize += f.length;
    }

    // Write the end-of-header...
    io.write(eoh, 0, eoh.length, off);

    // Write the main header...
    Convert.packB(hb, 0, (byte)fileCode);
    Convert.packB(hb, 1, (byte)(now.getYear() % 100));
    Convert.packB(hb, 2, (byte)(now.getMonth()));
    Convert.packB(hb, 3, (byte)(now.getDay()));
    Convert.packL(hb, 4, fileLength, EEEI);
    Convert.packI(hb, 8, (short)dataStart,  EEEI);
    Convert.packI(hb,10, (short)recordSize, EEEI);

    io.write(hb, 0, BUFSZ, 0);
  }

  /** Converts an element offset to a byte offset.
   *  @param off The element offset.
   *  @return The byte offset.
   */
  private long toByteOffset (double off) {
    return (long)(dataStart+(off*recordSize));
  }

  /** Gets the fields as an array.
   *  @return The fields as an array.
   */
  public Field[] getFields () {
    return fields.toArray(new Field[fields.size()]);
  }

  @Override
  public Table getDataTable (double off) {
    if (off < 0) return null;
    byte[] db = new byte[recordSize];
    int status = io.read(db, 0, recordSize, toByteOffset(off));
    if (status <= 0) return null;

    Table tbl = new Table();
    if (db[0] == '*') tbl.put("_DELETED_", true);

    fields.forEach((f) -> {
      String str = Convert.unpackS(db,f.start,f.length);
      Object val;

      switch (f.type) {
        case 'D':
          str = str.trim();
          str = str.substring(0,4)+":"+str.substring(5,7)+":"+str.substring(7)+"::00:00:00";
          val = Double.valueOf(str);
          break;
        case 'N':
        case 'F':
          val = (f.numdec == 0)? Integer.valueOf(str.trim()) : Double.valueOf(str.trim());
          break;
        case 'L':
          if (Shell.getMidasContext().io.isOptionSet(
              CoreIO.IOOptions.ShapeFileBooleanAsString)) {
            if (StringUtil.isTrue(str.trim().toUpperCase())) {
              val = "TRUE";
            }
            else
              if (StringUtil.isTrue(str.trim().toUpperCase())) {
                val = "FALSE";
              }
              else {
                val = "?";
              }
          }
          else {
            val = (str.isEmpty() || str.equals("?")) ? null : StringUtil.isTrue(str);
          }
          break;
        default:
          val = str;
          break;
      }

      tbl.setKey(f.name, val);
    });
    return tbl;
  }

  @Override  // Implemented since NeXtMidas 3.9.0
  public int setData (double off, Table tbl) {
    if (off < 0) return 0;
    byte[] db = new byte[recordSize];
    if (off > fileLength) {
      fileLength = (int)off;
      io.setLength(toByteOffset(fileLength));
    }

    db[0] = (byte)((tbl.getState("_DELETED_", false))? '*' : ' ');

    fields.forEach((f) -> {
      String str;
      switch (f.type) {
        case 'D':
          Time t = Time.toTime(tbl.get(f.name));
          str = t.toString(Time.FILENAME).substring(0,8);
          break;
        case 'F':
        case 'N':
          BigDecimal num = new BigDecimal(tbl.getS(f.name,"0"));
          str = num.setScale(f.numdec, RoundingMode.HALF_EVEN).toString();
          str = StringUtil.padLeft(str, f.length);
          break;
        case 'L':
          switch (tbl.getS(f.name, "").toUpperCase()) {
            case "?":     str = "?";                               break;
            case " ":     str = "?";                               break;
            case "":      str = "?";                               break;
            case "NULL":  str = "?";                               break;
            default:      str = (tbl.getState(f.name))? "T" : "F"; break;
          }
          break;
        default:
          str = tbl.getS(f.name, "");
          break;
      }
      Convert.packS(db, f.start, f.length, str);
    });
    io.write(db, 0, db.length, toByteOffset(off));
    return 1;
  }

  @Override  // Implemented since NeXtMidas 3.9.0
  public void setData (double off, String fieldName, Object obj) {
    Table tbl = getDataTable(off);
    if (tbl == null) tbl = new Table();
    tbl.put(fieldName, obj);
    setData(off, tbl);
  }

  @Override  // Implemented since NeXtMidas 3.9.0
  public void insertData (double off, Object obj) {
    if (off < 0) off = getSize(); // if it is negative, insert at end
    insertBytes(toByteOffset(off), recordSize); // added 1 to the offset so that it is inserted after the last element
    fileLength++; // Need to increase the record count here
    setData(off, (Table) obj);
  }

  @Override  // Implemented since NeXtMidas 3.9.0
  public void removeData (double off, long count) {
    if (count < 0) count = (long) (fileLength - off); // if less than 0, delete to end
    removeBytes(toByteOffset(off), count * recordSize);
    // subtract the amount of elements removed
    fileLength -= (int) (count);
  }

  @Override  // Implemented since NeXtMidas 3.9.0
  public void setRecordDefs (Table defs) {
    int start = 1;
    fields.clear();
    for (String key : defs.getKeys()) {
      Field f = new Field(this, defs.getTable(key), start);
      start += f.getLength();
      fields.add(f);
    }
    update();
  }

  @Override
  public Table getRecordDefs () {
    Table tbl = new Table();
    fields.forEach((f) -> tbl.put(f.name, f.toTable()));
    return tbl;
  }

  @Override
  public Table getRecordDef (int i) {
    return fields.get(i).toTable();
  }


  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Nested Classes
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** A field definition. */
  public class Field implements Tablizable {
    protected final byte[]   buf;
    protected final DbfFile  dbf;
    /** Field name */
    public final String name;
    /** Field format */
    public final String format;
    /** Field type */
    public final char type;
    /** Field start index */
    public final int start;
    /** Field length */
    public final int length;
    /** number of decimals */
    public final int numdec;


    /** Creates a new instance, initialized using the file content.
     *  @param dbf   The file.
     *  @param start The start index.
     */
    protected Field (DbfFile dbf, int start) {
      this.buf   = new byte[BUFSZ];
      this.dbf   = dbf;
      this.start = start;

      dbf.read(buf,0,BUFSZ);
      this.name   = Convert.unpackS(buf,0,11);
      this.type   = Character.toUpperCase(Convert.unpackC(buf,11));
      this.length = Convert.unpackB(buf,16) & 0xFF;
      this.numdec = Convert.unpackB(buf,17) & 0xFF;
      switch (type) {
        case 'D': this.format = "SD";                       break;
        case 'F': this.format = (numdec == 0)? "SL" : "SD"; break;
        case 'N': this.format = (numdec == 0)? "SL" : "SD"; break;
        case 'L': this.format = "1A";                       break;
        default:
          int n = Math.max(1, (length+7) / 8);
               if (n <=  9) this.format = n+"A";
          else if (n == 10) this.format = "XA";
          else if (n <= 16) this.format = "TA";
          else              this.format = "AA";
          break;
      }
    }

    /** Creates a new instance, initialized using the table content.
     *  @param dbf   The file.
     *  @param def   The record def.
     *  @param start The start index.
     */
    protected Field (DbfFile dbf, Table def, int start) {
      this.buf    = new byte[BUFSZ];
      this.dbf    = dbf;
      this.name   = def.getS("NAME");
      this.format = def.getS("FORMAT");
      this.start  = start;

      String defType;
      int    defLength;
      int    defNumdec;

      // Get defaults...
      //   Note: 19,15 is the maximum permitted for 'N'
      switch (format.charAt(1)) {
        case ASCII:    defType = "C"; defLength = Data.getBPA(format); defNumdec =  0; break;
        case ASCII256: defType = "C"; defLength = Data.getBPA(format); defNumdec =  0; break; // 3.9.4 NXM-3570
        case NIBBLE:   defType = "N"; defLength =                   4; defNumdec =  0; break;
        case BYTE:     defType = "N"; defLength =                   4; defNumdec =  0; break;
        case INT:      defType = "N"; defLength =                   6; defNumdec =  0; break;
        case LONG:     defType = "N"; defLength =                  12; defNumdec =  0; break;
        case XLONG:    defType = "N"; defLength =                  19; defNumdec =  0; break;
        case FLOAT:    defType = "N"; defLength =                  19; defNumdec = 15; break;
        case DOUBLE:   defType = "N"; defLength =                  19; defNumdec = 15; break;
        default:  throw new IllegalArgumentException("Bad format: "+format);
      }

      // Get user-supplied values...
      char _type   = def.getS("TYPE",   defType).charAt(0);
      int  _length = def.getL("LENGTH", defLength);
      int  _numdec = def.getL("NUMDEC", defNumdec);

      // Enforce limits...
      switch (_type) {
        case 'C': _length = Math.min(254,_length); _numdec = 0;               break;
        case 'D': _length = 8;                _numdec = 0;               break;
        case 'F': _length = Math.min(19,_length);  _numdec = Math.min(15,_numdec); break;
        case 'L': _length = 1;                _numdec = 0;               break;
        case 'M': _length = 10;               _numdec = 0;               break;
        case 'N': _length = Math.min(19,_length);  _numdec = Math.min(15,_numdec); break;
        default:  throw new IllegalArgumentException("Invalid DBF type: "+_type);
      }
      this.type   = _type;
      this.length = _length;
      this.numdec = _numdec;

      // Pack the buffer...
      Convert.packS(buf,  0, 11, StringUtil.padRight(name, 11, '\0'));
      Convert.packC(buf, 11,     type);
      Convert.packB(buf, 16,     (byte)length);
      Convert.packB(buf, 17,     (byte)numdec);
    }

    /** Reads the field from the file starting at the current offset.
     *  @param dbf The file.
     *  @return The length.
     *  @deprecated Since NeXtMidas 3.9.0 - This should never have been public.
     */
    @Deprecated
    public int readFrom (DbfFile dbf) {
      throw new UnsupportedOperationException();
    }

    /** Gets the length.
     *  @return The length of the field.
     */
    @InternalUseOnly("This method should be marked 'protected' as it only used when reading from "
                    +"a DBF file")
    public int getLength () {
      return length;
    }

    @Override
    public String toString () {
      return "DBF Field: "+toTable();
    }

    @Override
    public Table toTable () {
      Table tbl = new Table();
      tbl.setKey("NAME",   name  );
      tbl.setKey("FORMAT", format);
      tbl.setKey("TYPE",   type  );
      tbl.setKey("LENGTH", length);
      tbl.setKey("NUMDEC", numdec);
      return tbl;
    }
  }


  /** This is not used.
   *  @return The records.
   *  @deprecated Since NeXtMidas 3.9.0 -- This is not used.
   */
  @Deprecated
  public Record[] getRecords () {
    ArrayList<Record> list = new ArrayList<>(1024);
    for (; dataStart<fileLength; ) {
      Record record = new Record();
      dataStart += record.readFrom(this);
      list.add(record);
    }
    Record[] records = list.toArray(new Record[list.size()]);
    return records;
  }

  /** This is not used.
   *  @deprecated Since NeXtMidas 3.9.0 -- This is not used.
   */
  @Deprecated
  public class Record {
    /** data buffer */
    public byte[] db = new byte[recordSize];

    /** default constructor */
    public Record () {}

    /** Return size of Record
        @return Record size
     */
    public int getLength () {
      return recordSize;
    }

    /** read from DBF file
     *
     *  @param sf DBF file to read
     *  @return size of record
     */
    public int readFrom (DbfFile sf) {
      sf.read(db,0,52);
      return getLength();
    }
  }

}
