/*
 * JournalEntryDialog.java
 *
 * Status: Functional
 *
 * Todo list:
 *   Figure out cell focus issue.
 *   Consider giving CellEditor an InputVerifier to limit decimal places to 2
 *     and exclude negative numbers.
 *   Evaluate feasability of setting default amounts to balance transaction.
 */

package gui.dialog;

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import javax.swing.border.*;
import javax.swing.table.*;

import java.util.Date;
import java.util.LinkedList;

import finance.*;
import gui.ActionHandler;
import gui.Constants;
import gui.Main;

/**
 * The dialog for adding and editing journal entries.  This is a Singleton class
 * interfaced with through the static methods {@link #showAdd showAdd} and
 * {@link #showEdit showEdit}.
 */
public final class JournalEntryDialog extends JDialog
 {
  private static class JournalEntryTableModel extends AbstractTableModel
   {
    LinkedList<AccountEntry> entries = null;
    BalanceType type = null;

    JournalEntryTableModel(LinkedList<AccountEntry> list, BalanceType balanceType)
     {
      entries = list;
      type = balanceType;
     }

    public int getRowCount()
     { return entries.size() + 1; }

    public int getColumnCount()
     { return COLUMN_COUNT; }

    public Object getValueAt(int row, int column)
     {
      if(row < entries.size())
       {
        if(column == ACCOUNT_COL)
          return entries.get(row).getAccountName();
        else
          return entries.get(row);
       }
      else
        return null;
     }

    public void setValueAt(Object aValue, int row, int column)
     {
      Account account = null;
      if(column == ACCOUNT_COL)
        account = Main.getDB().getAccountByName((String)aValue);
      else
        account = Main.getDB().getAccountByName((String)getValueAt(row, ACCOUNT_COL));

      if(account == null)
        return;

      Amount amount = null;
      if(column == AMOUNT_COL)
        try
         { amount = new Amount((String)aValue, type); }
        catch(Exception ignored)
         { }
      else
        try
         { amount = ((AccountEntry)getValueAt(row, AMOUNT_COL)).getAmount(); }
        catch(Exception ignored)
         { }

      if(amount == null)
        amount = new Amount("0", type);

      AccountEntry entry = new AccountEntry(account, amount);

      if(row == entries.size())
       { entries.add(entry); }
      else
       { entries.set(row, entry); }

      if(column == ACCOUNT_COL)
        fireTableRowsUpdated(row, row); //Notify the amount cell of the update
     }

    public boolean isCellEditable(int row, int column)
     { return true; }

    public String getColumnName(int column)
     {
      switch(column)
       {
        case 0:  return "Account";
        case 1:  return "Amount";
        default: return super.getColumnName(column);
       }
     }
   }


  /**
   * The cell renderer for the debit and credit tables.
   */
  private static class CellRenderer extends JLabel implements TableCellRenderer
   {
    protected static Border selectedBorder = BorderFactory.createMatteBorder(1, 0, 1, 0, SystemColor.textHighlight);
    protected static Border unselectedBorder = BorderFactory.createEmptyBorder(1, 0, 1, 0);

    protected BalanceType type = null;


    public CellRenderer(BalanceType balanceType)
     {
      super();
      setOpaque(true);
      type = balanceType;
     }


    public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
     {
      if(isSelected)
        setBorder(selectedBorder);
      else
        setBorder(unselectedBorder);

      setBackground(row % 2 == 0 ? Constants.bgRowEven : Constants.bgRowOdd);
      setFont(table.getFont());

      if(value == null)
       {
        setText("");
       }
      else if(column == ACCOUNT_COL)
       {
        setHorizontalAlignment(SwingConstants.LEFT);
        setText(value.toString());
       }
      else if(value instanceof AccountEntry)
       {
        AccountEntry entry = (AccountEntry)value;
        setHorizontalAlignment(SwingConstants.RIGHT);
        setText(String.format("%.2f", entry.getAmount().getValue()));
        setBackground(entry.getBalanceType() == type ? Constants.bgBalanceNormal : Constants.bgBalanceAbnormal);
       }

      return this;
     }
   }


  /**
   * The {@link CellEditor CellEditor} used to edit cells in the amount columns
   * of the {@code JournalEntryDialog}'s debit and credit tables.
   */
  private static class AmountCellEditor extends DefaultCellEditor
   {
    /** The editor component. */
    JTextField editor = null;

    /**
     * Constructs an {@code AmountCellEditor}.
     */
    AmountCellEditor()
     {
      super(new JTextField());
      editor = (JTextField)getComponent();
      editor.setHorizontalAlignment(SwingConstants.RIGHT);
     }

    /**
     * Overriden to pass the {@link Amount Amount}'s string value to the editor.
     */
    public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column)
     {
      if(value != null && value instanceof AccountEntry)
       {
        String s = String.format("%.2f", ((AccountEntry)value).getAmount().getValue());
        return super.getTableCellEditorComponent(table, s, isSelected, row, column);
       }
      else
        return super.getTableCellEditorComponent(table, value, isSelected, row, column);
     }
   }


  /** The only instance of this class. */
  private static final JournalEntryDialog instance = new JournalEntryDialog();


  /** The index of the account column. */
  private static final int ACCOUNT_COL = 0;
  /** The index of the amount column. */
  private static final int AMOUNT_COL = 1;
  /** The number of columns in the debit and credit tables. */
  private static final int COLUMN_COUNT = 2;


  /** The text field used to edit the date. */
  private JTextField textDate = new JTextField();
  /** The text field used to edit the description. */
  private JTextField textDescription = new JTextField();
  /** The combo box used as a CellEditor to select account names in the debit and credit tables. */
  private JComboBox comboBox = new JComboBox();

  /** The list of entries for the debit table. */
  private LinkedList<AccountEntry> debits = new LinkedList<AccountEntry>();
  /** The list of entries for the credit table. */
  private LinkedList<AccountEntry> credits = new LinkedList<AccountEntry>();


  /** Validated value for entry date. */
  private Date validDate = null;
  /** Validated value for entry description. */
  private String validDescription = null;
  /** Validated value for account entries. */
  private LinkedList<AccountEntry> validEntries = new LinkedList<AccountEntry>();


  /** Identifies if the dialog was cancelled or not. */
  private boolean cancelled = true;

  /**
   * Constructs the {@code JournalEntryDialog}.
   */
  private JournalEntryDialog()
   {
    super(Main.instance, true);
    setSize(600, 300);
    setLayout(new GridBagLayout());
    GridBagConstraints c = new GridBagConstraints();

    //Add the date label and field
    c.gridwidth = 1;
    c.weightx = 0;
    c.fill = GridBagConstraints.BOTH;
    c.anchor = GridBagConstraints.NORTHWEST;
    add(new JLabel("Date"), c);
    c.weightx = 1;
    c.gridwidth = GridBagConstraints.REMAINDER;
    add(textDate, c);

    //Add the description label and field
    c.weightx = 0;
    c.gridwidth = 1;
    add(new JLabel("Description"), c);
    c.weightx = 1;
    c.gridwidth = GridBagConstraints.REMAINDER;
    add(textDescription, c);

    //Add the debits label and table
    add(new JLabel("Debits"), c);
    c.weighty = 1;
    JTable table = new JTable(new JournalEntryTableModel(debits, BalanceType.DEBIT));
    table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
    table.getTableHeader().setReorderingAllowed(false);
    table.getColumnModel().getColumn(ACCOUNT_COL).setCellRenderer(new CellRenderer(BalanceType.DEBIT));
    table.getColumnModel().getColumn(ACCOUNT_COL).setCellEditor(new DefaultCellEditor(comboBox));
    table.getColumnModel().getColumn(AMOUNT_COL).setCellRenderer(new CellRenderer(BalanceType.DEBIT));
    table.getColumnModel().getColumn(AMOUNT_COL).setCellEditor(new AmountCellEditor());
    table.getColumnModel().getColumn(AMOUNT_COL).setResizable(false);
    setColumnWidths(table);
    add(new JScrollPane(table), c);

    //Add the credits label and table
    c.weighty = 0;
    add(new JLabel("Credits"), c);
    c.weighty = 1;
    table = new JTable(new JournalEntryTableModel(credits, BalanceType.CREDIT));
    table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
    table.getTableHeader().setReorderingAllowed(false);
    table.getColumnModel().getColumn(ACCOUNT_COL).setCellRenderer(new CellRenderer(BalanceType.CREDIT));
    table.getColumnModel().getColumn(ACCOUNT_COL).setCellEditor(new DefaultCellEditor(comboBox));
    table.getColumnModel().getColumn(AMOUNT_COL).setCellRenderer(new CellRenderer(BalanceType.CREDIT));
    table.getColumnModel().getColumn(AMOUNT_COL).setCellEditor(new AmountCellEditor());
    table.getColumnModel().getColumn(AMOUNT_COL).setResizable(false);
    setColumnWidths(table);
    add(new JScrollPane(table), c);

    //Add the OK and Cancel buttons
    c.weighty = 0;
    c.weightx = 1;
    c.gridwidth = 1;
    JPanel panel = new JPanel();
    panel.setLayout(new GridBagLayout());
    JButton button = new JButton("OK");
    button.addActionListener(new ActionHandler(this, "button_OK_Handler"));
    panel.add(button, c);
    button = new JButton("Cancel");
    button.addActionListener(new ActionHandler(this, "button_Cancel_Handler"));
    panel.add(button, c);
    c.gridwidth = GridBagConstraints.REMAINDER;
    add(panel, c);
   }


  /**
   * Called by the constructor to set the column widths for the debit and credit
   * tables.
   *
   * @param table the table to set the column widths for.
   */
  private void setColumnWidths(JTable table)
   {
    String numberString = "$90,000.00";
    int numberWidth = 0;
    TableColumn column = null;

    try
     {
      Graphics2D g = this.getGraphicsConfiguration().createCompatibleImage(1, 1).createGraphics();
      numberWidth = (int)table.getFont().getStringBounds(numberString, g.getFontRenderContext()).getWidth();
     }
    catch(Exception e)
     { return; }

    numberWidth *= 1.3;

    column = table.getColumnModel().getColumn(AMOUNT_COL);
    column.setMinWidth(numberWidth);
    column.setMaxWidth(numberWidth);
   }


  /**
   * Resets the dialog's data, preparing it to add or edit an entry.
   */
  private void reset()
   {
    textDate.setText("");
    textDescription.setText("");
    debits.clear();
    credits.clear();

    comboBox.removeAllItems();
    for(String s : Main.getDB().getAccountNames())
      comboBox.addItem(s);

    validDate = null;
    validDescription = null;
    validEntries.clear();

    cancelled = true;
   }


  /**
   * Shows a {@code JournalEntryDialog} in "Add Entry" mode.
   *
   * @param journal the {@code Journal} to which the new entry will be added.
   */
  public static void showAdd(Journal journal)
   { instance.mShowAdd(journal); }


  /**
   * Shows a {@code JournalEntryDialog} in "Edit Entry" mode.
   *
   * @param journal the {@code Journal} containing the entry to be edited.
   * @param entry the {@code JournalEntry} to edit.
   */
  public static void showEdit(Journal journal, JournalEntry entry)
   { instance.mShowEdit(journal, entry); }


  /**
   * Shows a {@code JournalEntryDialog} in "Add Entry" mode. This is an internal
   * method used by the static {@link #showAdd showAdd} method.
   *
   * @param journal the {@code Journal} to which the new entry will be added.
   */
  private void mShowAdd(Journal journal)
   {
    reset();
    setTitle("Add Journal Entry");

    textDate.setText(Constants.dateLong.format(new Date()));

    setVisible(true);
    if(cancelled)
      return;

    JournalEntry entry = constructEntry(journal, null);
    if(entry != null)
      journal.addOrUpdate(entry);
   }


  /**
   * Shows a {@code JournalEntryDialog} in "Edit Entry" mode. This is an
   * internal method used by the static {@link #showEdit showEdit} method.
   *
   * @param journal the {@code Journal} containing the entry to be edited.
   * @param entry the {@code JournalEntry} to edit.
   */
  private void mShowEdit(Journal journal, JournalEntry entry)
   {
    reset();
    setTitle("Edit Journal Entry");

    for(AccountEntry e : entry.getAmounts())
      if(e.getAmount().getType() == BalanceType.DEBIT)
        debits.add(AccountEntry.copy(e));
      else
        credits.add(AccountEntry.copy(e));

    textDate.setText(Constants.dateLong.format(entry.getDate()));
    textDescription.setText(entry.getDescription());

    setVisible(true);
    if(cancelled)
      return;

    entry = constructEntry(journal, entry);
    if(entry != null)
      journal.addOrUpdate(entry);
   }


  /**
   * Constructs a new {@code JournalEntry} from the dialog's data.
   *
   * @param journal the {@code Journal} that will contain the new entry.
   * @param entry the {@code JournalEntry} being edited, or {@code null} for a
   *        new entry.
   */
  private JournalEntry constructEntry(Journal journal, JournalEntry entry)
   {
    if(entry == null)
      entry = journal.newEntry(validDate, validDescription);
    else
      entry = journal.newEntry(entry, validDate, validDescription);

    for(AccountEntry e : validEntries)
      entry.add(e);

    return entry;
   }


  /**
   * Checks the dialog's data for validity.
   *
   * @return {@code true} if the data is valid, {@code false} if it is invalid.
   */
  private boolean validateData()
   {
    String error = null;

    //Validate date
    try
     {
      validDate = Constants.dateShort.parse(textDate.getText().trim());
      validDate = Constants.dateLong.parse(textDate.getText().trim());
     }
    catch(java.text.ParseException e)
     {
      if(validDate == null)
        error = appendLine(error, "Invalid date/time format.");
     }

    //Validate entries
    Amount amount = Amount.ZERO;
    LinkedList<AccountEntry> entries = new LinkedList<AccountEntry>();
    LinkedList<Account> accounts = new LinkedList<Account>();
    LinkedList<Account> repeatedAccounts = new LinkedList<Account>();
    entries.addAll(debits);
    entries.addAll(credits);
    for(AccountEntry e : entries)
     {
      if(Amount.ZERO.getValue().compareTo(e.getAmount().getValue()) == 0)
        continue;

      amount = amount.add(e.getAmount());
      if(!accounts.contains(e.getAccount()))
       {
        accounts.add(e.getAccount());
        validEntries.add(e);
       }
      else if(!repeatedAccounts.contains(e.getAccount()))
       {
        repeatedAccounts.add(e.getAccount());
        error = appendLine(error, "Account \"" + e.getAccountName() + "\" occurs more than once.");
       }
     }
    if(validEntries.size() == 0)
      error = appendLine(error, "No valid entries.");
    else if(Amount.ZERO.getValue().compareTo(amount.getValue()) != 0)
      error = appendLine(error, "Debits must equal credits.");

    //Validate description
    validDescription = textDescription.getText();
    if(validDescription == null)
      validDescription = "";

    if(error == null)
      return true;

    validDate = null;
    validDescription = null;
    validEntries.clear();

    errorMessage(error);

    return false;
   }


  /**
   * Appends {@code end} to {@code start}, with a newline between them.  Either
   * or both parameters may be {@code null}.  If one parameter is {@code null},
   * the non-{@code null} parameter is returned unchanged.  If both parameters
   * are {@code null}, {@code null} is returned.
   *
   * @param start the initial line(s) of the string.
   * @param end the string to be appended.
   *
   * @return {@code end} appended to {@code start}, with a newline between them
   * unless one is {@code null}, or {@code null} if {@code start} and {@code
   * end} are both {@code null}.
   */
  private String appendLine(String start, String end)
   {
    if(end == null)
      return start;
    else if(start == null)
      return end;
    else
      return start + "\n" + end;
   }


  /**
   * Displays an error message to the user.
   *
   * @param message the error message to display.
   */
  private void errorMessage(String message)
   { JOptionPane.showMessageDialog(this, message, "Validation Error", JOptionPane.ERROR_MESSAGE); }


  /**
   * Attempts to validate the input, and closes the dialog if successful.  This
   * is the {@link ActionListener ActionListener} method for the "OK" button.
   */
  public void button_OK_Handler(ActionEvent event)
   {
    if(!validateData())
      return;

    cancelled = false;
    setVisible(false);
   }


  /**
   * Closes the dialog and cancels the current add or edit.  This is the {@link
   * ActionListener ActionListener} method for the "Cancel" button.
   */
  public void button_Cancel_Handler(ActionEvent event)
   { setVisible(false); }
 }