/*
 * Account.java
 *
 * Status: Functional
 *
 * Notes:
 *   Documentation complete for DataModel class.
 *
 * Todo list:
 *   Remove curBalance from byte conversion (calculated value, should not be serialized).
 *   Document class, entriesFoo, configureSets.
 *   Review add and remove.
 */

package finance;

import java.nio.ByteBuffer;
import java.util.*;
import javax.swing.table.TableModel;
import javax.swing.table.AbstractTableModel;

import utility.Status;

/**
 * An account tracks changes to the value of an asset or liability.
 */
public final class Account
 {
  /**
   * The data model used by tables to display an {@code Account}'s data.
   */
  public static final class DataModel extends AbstractTableModel
   {
    /** The index of the date column. */
    public static final int DATE_COL = 0;
    /** The index of the description column. */
    public static final int DESC_COL = 1;
    /** The index of the debit column. */
    public static final int DEBIT_COL = 2;
    /** The index of the credit column. */
    public static final int CREDIT_COL = 3;
    /** The index of the balance column. */
    public static final int BALANCE_COL = 4;
    /** The number of columns in the table. */
    public static final int COLUMN_COUNT = 5;

    /** The {@code Account} supplying this model's data. */
    Account account = null;
    /** Local {@code List} representation of the {@code Account}'s entries. */
    ArrayList<AccountEntry> list = null;


    /**
     * Constructs a new {@code DataModel} for the specified {@code Account}.
     *
     * @param account the {@code Account} that will supply this model's data.
     */
    DataModel(Account account)
     {
      this.account = account;
      updateList();
     }


    /**
     * Updates the local {@code List} copy of the {@code Account}'s entries.
     */
    private void updateList()
     { list = new ArrayList<AccountEntry>(account.entries); }


    /**
     * Gets the number of rows in this data model, which is the number of
     * entries in the {@code Account}.
     *
     * @return the number of rows in this data model.
     */
    public int getRowCount()
     { return list.size(); }


    /**
     * Gets the number of columns in this data model.  There are five columns,
     * as defined by {@link #COLUMN_COUNT COLUMN_COUNT}, for date, description,
     * debit, credit, and balance.
     *
     * @return the number of columns in this data model.
     */
    public int getColumnCount()
     { return COLUMN_COUNT; }


    /**
     * Gets the {@link AccountEntry AccountEntry} for the specified row.
     *
     * @param row the row coordinate of the desired value.
     * @param column the column coordinate of the desired value.
     *
     * @return the {@link AccountEntry AccountEntry} for the specified row, or
     * {@code null} if {@code row} or {@code column} is out of bounds.
     */
    public Object getValueAt(int row, int column)
     {
      if(column >= 0 && column < COLUMN_COUNT && row >= 0 && row < getRowCount())
        return list.get(row);
      else
        return null;
     }


    /**
     * Gets the name of the specified column.
     *
     * @param column the column whose name is desired.
     *
     * @return the name of the specified column.
     */
    public String getColumnName(int column)
     {
      switch(column)
       {
        case DATE_COL:
          return "Date";
        case DESC_COL:
          return "Description";
        case DEBIT_COL:
          return "Debit";
        case CREDIT_COL:
          return "Credit";
        case BALANCE_COL:
          return "Balance";
        default:
          return super.getColumnName(column);
       }
     }
   }


  /** The next available unique identifier for this {@code Account}. */
  private static int newID = 0;


  /**
   * Converts an {@code Account} object into an array of bytes suitable for
   * writing to a file.
   *
   * @param obj the {@code Account} object to convert.
   *
   * @return an array of bytes representing the given {@code Account} object.
   */
  public static byte[] convertToBytes(Account obj)
   {
    int size = 0;
    byte[] intBytes = new byte[4];
    ByteBuffer intBuf = ByteBuffer.wrap(intBytes);

    byte[] idBytes = new byte[4];
    ByteBuffer.wrap(idBytes).putInt(obj.id);
    size += 4;

    byte[] typeBytes = new byte[4];
    ByteBuffer.wrap(typeBytes).putInt(obj.type.getID());
    size += 4;

    byte[] nameBytes = obj.name.getBytes();
    size += nameBytes.length + 4;

    byte[] numberBytes = obj.number.getBytes();
    size += numberBytes.length + 4;

    byte[] refDateBytes = new byte[8];
    ByteBuffer.wrap(refDateBytes).putLong(obj.refDate.getTime());
    size += 8;

    byte[] refBalanceBytes = Amount.convertToBytes(obj.refBalance);
    size += refBalanceBytes.length + 4;

    byte[] curBalanceBytes = Amount.convertToBytes(obj.currentBalance);
    size += curBalanceBytes.length + 4;

    byte[] commentsBytes = obj.comments.getBytes();
    size += commentsBytes.length + 4;

    byte[] bytes = new byte[size];
    int index = 0;

    System.arraycopy(idBytes, 0, bytes, index, 4);
    index += 4;

    System.arraycopy(typeBytes, 0, bytes, index, 4);
    index += 4;

    intBuf.putInt(0, nameBytes.length);
    System.arraycopy(intBytes, 0, bytes, index, 4);
    index += 4;
    System.arraycopy(nameBytes, 0, bytes, index, nameBytes.length);
    index += nameBytes.length;

    intBuf.putInt(0, numberBytes.length);
    System.arraycopy(intBytes, 0, bytes, index, 4);
    index += 4;
    System.arraycopy(numberBytes, 0, bytes, index, numberBytes.length);
    index += numberBytes.length;

    System.arraycopy(refDateBytes, 0, bytes, index, 8);
    index += 8;

    intBuf.putInt(0, refBalanceBytes.length);
    System.arraycopy(intBytes, 0, bytes, index, 4);
    index += 4;
    System.arraycopy(refBalanceBytes, 0, bytes, index, refBalanceBytes.length);
    index += refBalanceBytes.length;

    intBuf.putInt(0, curBalanceBytes.length);
    System.arraycopy(intBytes, 0, bytes, index, 4);
    index += 4;
    System.arraycopy(curBalanceBytes, 0, bytes, index, curBalanceBytes.length);
    index += curBalanceBytes.length;

    intBuf.putInt(0, commentsBytes.length);
    System.arraycopy(intBytes, 0, bytes, index, 4);
    index += 4;
    System.arraycopy(commentsBytes, 0, bytes, index, commentsBytes.length);
    index += commentsBytes.length;

    return bytes;
   }


  /**
   * Converts an array of bytes into an {@code Account} object.
   *
   * @param bytes the array of bytes to convert.
   *
   * @return an {@code Account} object constructed from the given array of
   * bytes.
   */
  public static Account convertFromBytes(byte[] bytes)
   {
    Account ret = new Account();
    int index = 0;
    int length = 0;

    ret.id = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    if(newID <= ret.id)
      newID = ret.id + 1;

    int type = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    ret.type = gui.Main.getDB().getAccountType(type);

    length = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    byte[] nameBytes = new byte[length];
    System.arraycopy(bytes, index, nameBytes, 0, length);
    index += length;
    ret.name = new String(nameBytes);

    length = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    byte[] numberBytes = new byte[length];
    System.arraycopy(bytes, index, numberBytes, 0, length);
    index += length;
    ret.number = new String(numberBytes);

    long refDate = ByteBuffer.wrap(bytes, index, 8).getLong();
    index += 8;
    ret.refDate = new Date(refDate);

    length = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    byte[] refBalanceBytes = new byte[length];
    System.arraycopy(bytes, index, refBalanceBytes, 0, length);
    index += length;
    ret.refBalance = Amount.convertFromBytes(refBalanceBytes);

    length = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    byte[] curBalanceBytes = new byte[length];
    System.arraycopy(bytes, index, curBalanceBytes, 0, length);
    index += length;
    ret.currentBalance = ret.refBalance;

    length = ByteBuffer.wrap(bytes, index, 4).getInt();
    index += 4;
    byte[] commentsBytes = new byte[length];
    System.arraycopy(bytes, index, commentsBytes, 0, length);
    index += length;
    ret.comments = new String(commentsBytes);

    ret.configureSets();

    return ret;
   }


  /** A unique identifier for this account, for internal use. */
  private int id;
  /** The type of this account, e.g.&nbsp;asset, liability, expense, revenue. */
  AccountType type;
  /** The name of this account, e.g.&nbsp;Savings; must be unique. */
  String name;
  /** An optional identifier for this account, typically an account number; must be unique if present. */
  String number;
  /** The reference date for this account, typically the date the account was opened. */
  Date refDate;
  /** The reference balance for this account, typically zero. */
  Amount refBalance;
  /** The current balance of this account, calculated from the reference balance and entries. */
  Amount currentBalance;
  /** User comments about this account. */
  String comments;

  /* TODO: document */
  //LinkedList<AccountEntry> entries = new LinkedList<AccountEntry>();
  TreeSet<AccountEntry> entries = new TreeSet<AccountEntry>(AccountEntry.COMPARATOR);
  SortedSet<AccountEntry> entriesHead = null;
  SortedSet<AccountEntry> entriesTail = null;

  /** The data model used to supply this {@code Account}'s data to views. */
  DataModel dataModel = null;


  /**
   * Internal constructor for use by {@link #convertFromBytes convertFromBytes}.
   */
  private Account()
   { }


  /**
   * Constructs a new {@code Account}.
   *
   * @param name the name for the account; may not identify an existing account;
   * may not be {@code null}.
   * @param number the number for the account; may not identify an existing
   * account; {@code null} is valid.
   * @param type the type of the account; may not be {@code null}.
   * @param date the reference date for the account; may not be {@code null}.
   * @param balance the reference balance for the account; may not be {@code
   * null}.
   * @param comments the user comments for the account; {@code null} is valid.
   *
   * @throws NullPointerException if {@code name}, {@code type}, {@code date},
   * or {@code balance} is {@code null}.
   * @throws IllegalArgumentException if {@code name} or {@code number}
   * identifies an existing account.
   */
  public Account(String name, String number, AccountType type, Date date, Amount balance, String comments)
   {
    if(name == null || type == null || date == null || balance == null)
      throw new NullPointerException();

    Account account = gui.Main.getDB().getAccountByName(name);
    if(account != null && account != this)
      throw new IllegalArgumentException();
    account = gui.Main.getDB().getAccountByNumber(number);
    if(account != null && account != this)
      throw new IllegalArgumentException();

    this.id = newID++;
    this.name = name;
    this.type = type;
    this.refDate = date;
    this.refBalance = balance;
    this.currentBalance = this.refBalance;
    this.number = number != null ? number : "";
    this.comments = comments != null ? comments : "";
    configureSets();
   }


  /**
   * Gets the ID of this {@code Account}.
   *
   * @return the unique identifier of this {@code Account}.
   */
  public int getID()
   { return id; }


  /**
   * Gets the {@link AccountType AccountType} of this {@code Account}.
   *
   * @return the type of this {@code Account}.
   */
  public AccountType getType()
   { return type; }


  /**
   * Gets the name of this {@code Account}.
   *
   * @return the name of this {@code Account}
   */
  public String getName()
   { return name; }


  /**
   * Gets the number of this {@code Account}.
   *
   * @return the number of this {@code Account}.
   */
  public String getNumber()
   { return number; }


  /**
   * Gets the reference date of this {@code Account}.
   *
   * @return the reference date of this {@code Account}.
   */
  public Date getReferenceDate()
   { return refDate; }


  /**
   * Gets the reference balance of this {@code Account}.
   *
   * @return the reference balance of this {@code Account}.
   */
  public Amount getReferenceBalance()
   { return refBalance; }


  /**
   * Gets the current balance of this {@code Account}.
   *
   * @return the current balance of this {@code Account}.
   */
  public Amount getBalance()
   { return currentBalance; }


  /**
   * Gets the user comments for this {@code Account}.
   *
   * @return the user comments for this {@code Account}.
   */
  public String getComments()
   { return comments; }


  /* TODO: document */
  public Collection<AccountEntry> getEntries()
   { return Collections.unmodifiableCollection(entries); }


  /**
   * Adds an entry to this {@code Account}.
   *
   * @param entry the entry to add.
   */
  void add(AccountEntry entry)
   {
    if(entry == null)
      return;

    entries.add(entry);
    entry.balance = type.getZero();

    ArrayList<AccountEntry> list = new ArrayList<AccountEntry>(entries);
    int index = getIndex(entry);
    int indexFirst = index;
    int indexLast = index;

    if(entry.getDate().compareTo(refDate) >= 0)
     {
      //entry is after the reference date, apply its amount to all following entries
      currentBalance = currentBalance.add(entry.amount);
      if(index > 0)
        entry.balance = list.get(index-1).balance.add(entry.amount);
      else
        entry.balance = entry.amount.add(refBalance);
      for(int i=index+1; i<list.size(); i++)
        list.get(i).balance = list.get(i).balance.add(entry.amount);
      indexFirst = index;
      indexLast = list.size()-1;
     }
    else
     {
      //entry is before the reference date, apply its amount to all preceeding entries
      if(index < entriesHead.size()-1)
        entry.balance = list.get(index+1).balance.subtract(list.get(index+1).amount);
      else
        entry.balance = refBalance;
      for(int i=index-1; i>=0; i--)
        list.get(i).balance = list.get(i).balance.subtract(entry.amount);
      indexFirst = 0;
      indexLast = index;
     }

    //Update the data model.
    fireAddEvent(getIndex(entry));
    if(dataModel != null)
      dataModel.fireTableRowsUpdated(indexFirst, indexLast);
   }


  /**
   * Removes an entry from this {@code Account}.
   *
   * @param entry the entry to remove.
   */
  void remove(AccountEntry entry)
   {
    if(entry == null)
      return;

    int index = getIndex(entry);

    entries.remove(entry);

    if(entry.getDate().compareTo(refDate) >= 0)
     {
      currentBalance = currentBalance.subtract(entry.amount);
      SortedSet<AccountEntry> set = entries.tailSet(entry);
      for(AccountEntry e : set)
        e.balance = e.balance.subtract(entry.amount);
     }
    else
     {
      SortedSet<AccountEntry> set = entries.headSet(entry);
      for(AccountEntry e : set)
        e.balance = e.balance.add(entry.amount);
     }

    fireRemoveEvent(index);
   }


  /**
   * Updates this account with the specified data.
   *
   * @param name the name for the account; may not identify another existing
   * account; may not be {@code null}.
   * @param number the number for the account; may not identify another existing
   * account; {@code null} is valid.
   * @param type the type of the account; may not be {@code null}.
   * @param date the reference date for the account; may not be {@code null}.
   * @param balance the reference balance for the account; may not be {@code
   * null}.
   * @param comments the user comments for the account; {@code null} is valid.
   *
   * @return {@link Status#ERROR Status.ERROR} if {@code name}, {@code type},
   * {@code date}, or {@code balance} is {@code null}, or if {@code name} or
   * {@code number} identifies another existing account; {@link Status#OK
   * Status.OK} if the update is successful.
   */
  public Status update(String name, String number, AccountType type, Date date, Amount balance, String comments)
   {
    if(name == null || type == null || date == null || balance == null)
      return Status.ERROR;

    Account account = gui.Main.getDB().getAccountByName(name);
    if(account != null && account != this)
      return Status.ERROR;
    account = gui.Main.getDB().getAccountByNumber(number);
    if(account != null && account != this)
      return Status.ERROR;

    boolean refChanged = false;
    if(!(date.equals(refDate) && balance.equals(refBalance)))
      refChanged = true;

    this.name = name;
    this.type = type;
    this.refDate = date;
    this.refBalance = balance;
    this.number = number != null ? number : "";
    this.comments = comments != null ? comments : "";

    if(!refChanged)
      return Status.OK;

    //The reference date changed, so the balance for all entries must be
    //recalculated.  Accomplished by re-adding all entries to the account.
    TreeSet<AccountEntry> allEntries = entries;
    entries = new TreeSet<AccountEntry>(AccountEntry.COMPARATOR);
    configureSets();
    currentBalance = refBalance;
    for(AccountEntry e : allEntries)
      add(e);
    if(dataModel != null)
     {
      dataModel.updateList();
      dataModel.fireTableRowsUpdated(0, entries.size()-1);
     }

    return Status.OK;
   }


  /**
   * Updates this {@code Account} with data from the specified {@code Account}.
   *
   * @param account the {@code Account} providing the updated data.
   *
   * @return {@link Status#ERROR Status.ERROR} if any of the data in the
   * specified {@code Account} is invalid for updating this account; {@link
   * Status#OK Status.OK} if the update is successful.
   */
  Status update(Account account)
   {
    return update(account.name, account.number, account.type, account.refDate,
                  account.refBalance, account.comments);
   }


  /* TODO: document */
  private void configureSets()
   {
    JournalEntry journalEntry = gui.Main.getDB().getJournal().newEntry(refDate, "");
    AccountEntry entry = new AccountEntry(this, type.getZero());
    journalEntry.add(entry);
    entriesHead = entries.headSet(entry);
    entriesTail = entries.tailSet(entry);
   }


  /**
   * Gets the index of the specified entry.
   *
   * @return the index of the specified entry.
   */
  private int getIndex(AccountEntry entry)
   {
    SortedSet<AccountEntry> set = entries.headSet(entry);
    return set.size();
   }


  /**
   * Gets the data model that views can use to display data for this
   * {@code Account}.
   *
   * @return the data model for this {@code Account}.
   */
  public TableModel getDataModel()
   {
    if(dataModel == null)
      dataModel = new DataModel(this);

    return dataModel;
   }


  /**
   * Notifies the data model that an entry has been added.
   *
   * @param index the index of the added entry.
   */
  private void fireAddEvent(int index)
   {
    if(dataModel == null)
      return;

    dataModel.updateList();
    dataModel.fireTableRowsInserted(index, index);
   }


  /**
   * Notifies the data model that an entry has been deleted.
   *
   * @param index the index of the deleted entry.
   */
  private void fireRemoveEvent(int index)
   {
    if(dataModel == null)
      return;

    dataModel.updateList();
    dataModel.fireTableRowsDeleted(index, index);
   }
 }