/*
 * Copyright 2007, 2008 Perry Nguyen <pfnguyen@hanhuy.com>
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.BorderLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.print.Paper;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterJob;
import java.awt.print.PrinterException;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.List;
import java.util.ArrayList;

import static java.math.BigDecimal.ONE;
import static java.math.BigDecimal.ROUND_HALF_UP;
import static java.math.BigDecimal.ROUND_UP;
import static java.math.BigDecimal.ZERO;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.ImageIcon;
import javax.swing.ListSelectionModel;
import javax.swing.JDialog;
import javax.swing.JFormattedTextField;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.KeyStroke;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingConstants;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.TableCellRenderer;
import javax.swing.table.TableColumn;
import javax.swing.text.NumberFormatter;

/**
 * http://en.wikipedia.org/wiki/Amortization_calculator is used to provide
 * the primary equation.  The equations assume that payments are made at the
 * end of the periods instead of beginning.
 */
public class TVM {
    private JFrame frame;
    private JTable table;
    private TableModel tableModel = new TableModel();
    private JPopupMenu tablePopup;

    private final static int SCALE = 9;
    private final static MathContext mc = new MathContext(SCALE,
                                                       RoundingMode.DOWN);
    // * 12 = 0.6% APR
    private final static BigDecimal ESTIMATE_START   = new BigDecimal(0.0005);
    private final static BigDecimal ESTIMATE_INCR    = new BigDecimal(0.0005);
    // * 12 = 20% APR
    private final static BigDecimal ESTIMATE_END     = new BigDecimal(0.1666);
    private final static BigDecimal ESTIMATE_DIVERGE = new BigDecimal(0.1);

    // combined with the below, it's 30 years
    private final static int DEFAULT_PERIODS = 360;
    private final static int DEFAULT_PER_YEAR = 12;

    // stabilize approximation to 9 digits
    private final static BigDecimal ESTIMATE_MARGIN  = new BigDecimal(
            0.0000000001);
    private JFormattedTextField principalField;
    private JFormattedTextField paymentField;
    private JFormattedTextField interestField;
    private JFormattedTextField paymentsField;
    private JFormattedTextField paymentsPerYearField;
    private static int windowCount = 0;

    public static void main(String args[]) {
        try {
            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
        }
        catch (ClassNotFoundException e) { e.printStackTrace(); }
        catch (InstantiationException e) { e.printStackTrace(); }
        catch (IllegalAccessException e) { e.printStackTrace(); }
        catch (UnsupportedLookAndFeelException e) { e.printStackTrace(); }

        final TVM tvm = new TVM();

        EventQueue.invokeLater(new Runnable() {
            public void run() {
                tvm.getFrame().setVisible(true);
            }
        });
    }

    public TVM() {
        windowCount++;

        frame = new JFrame("Amortization Calculator");
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                windowCount--;
                if (windowCount < 1)
                    System.exit(0);
            }
        });
        layout();
        center();
        ImageIcon icon =  new ImageIcon(
                getClass().getClassLoader().getResource("calculator_edit.png"));
        frame.setIconImage(icon.getImage());
    }

    private void layout() {
        layoutMenu();
        NumberFormat moneyFormat = NumberFormat.getInstance();
        moneyFormat.setMaximumFractionDigits(2);
        moneyFormat.setMinimumFractionDigits(2);
        NumberFormat intFormat = NumberFormat.getInstance();
        intFormat.setMaximumFractionDigits(6);
        intFormat.setMinimumFractionDigits(0);

        NumberFormatter principalFormat =
                new NumberFormatter(moneyFormat);
        principalFormat.setValueClass(Double.class);
        principalFormat.setMinimum(Double.valueOf(0.01));

        NumberFormatter paymentFormat =
                new NumberFormatter(moneyFormat);
        paymentFormat.setValueClass(Double.class);
        paymentFormat.setMinimum(Double.valueOf(0.01));

        NumberFormatter interestFormat =
                new NumberFormatter(intFormat);
        interestFormat.setValueClass(Double.class);
        interestFormat.setMinimum(Double.valueOf(0.0001));
        interestFormat.setMaximum(Double.valueOf(9999.9999));

        NumberFormatter paymentsFormat =
                new NumberFormatter(NumberFormat.getIntegerInstance());
        paymentsFormat.setValueClass(Integer.class);
        paymentsFormat.setMaximum(Integer.valueOf(3650));
        paymentsFormat.setMinimum(Integer.valueOf(1));

        NumberFormatter paymentsPerYearFormat =
                new NumberFormatter(NumberFormat.getIntegerInstance());
        paymentsPerYearFormat.setValueClass(Integer.class);
        paymentsPerYearFormat.setMaximum(Integer.valueOf(365));
        paymentsPerYearFormat.setMinimum(Integer.valueOf(1));

        principalField = new JFormattedTextField(principalFormat);
        principalField.setColumns(12);
        paymentField = new JFormattedTextField(paymentFormat);
        paymentField.setColumns(10);
        interestField = new JFormattedTextField(interestFormat);
        interestField.setColumns(5);
        paymentsField = new JFormattedTextField(paymentsFormat);
        paymentsField.setColumns(4);
        paymentsPerYearField = new JFormattedTextField(paymentsPerYearFormat);
        paymentsPerYearField.setColumns(3);

        paymentsField.setValue(DEFAULT_PERIODS);
        paymentsPerYearField.setValue(DEFAULT_PER_YEAR);

        JPanel panel = new JPanel();
        panel.add(newLabelledPanel("Principal", principalField));
        panel.add(newLabelledPanel("Interest Rate", interestField));
        panel.add(newLabelledPanel("Payment", paymentField));
        panel.add(newLabelledPanel("Payments", paymentsField));
        panel.add(newLabelledPanel("N/year", paymentsPerYearField));
        frame.add(panel, BorderLayout.NORTH);

        table = new JTable();
        table.addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                maybeShowPopup(e);
            }
            @Override
            public void mouseReleased(MouseEvent e) {
                maybeShowPopup(e);
            }
        });

        Action periodicAction = new AbstractAction() {
            public void actionPerformed(ActionEvent e) {
                startPeriodicPayments();
            }
        };
        periodicAction.putValue(Action.NAME,
                "Start periodic principal payments");
        periodicAction.putValue(Action.MNEMONIC_KEY, KeyEvent.VK_P);
        periodicAction.putValue(Action.ACCELERATOR_KEY,
                KeyStroke.getKeyStroke("control P"));
        Action oneTimeAction = new AbstractAction() {
            public void actionPerformed(ActionEvent e) {
                doOneTimePayment();
            }
        };
        oneTimeAction.putValue(Action.NAME,
                "Insert a one-time principal payment");
        oneTimeAction.putValue(Action.MNEMONIC_KEY, KeyEvent.VK_O);
        oneTimeAction.putValue(Action.ACCELERATOR_KEY,
                KeyStroke.getKeyStroke("control O"));

        Dimension size = table.getPreferredScrollableViewportSize();
        int height = table.getRowHeight();
        size.height = height * 20;
        table.setPreferredScrollableViewportSize(size);
        table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
        table.setModel(tableModel);
        table.getActionMap().put(
                oneTimeAction.getValue(Action.NAME),
                oneTimeAction);
        table.getInputMap().put(
                (KeyStroke) oneTimeAction.getValue(Action.ACCELERATOR_KEY),
                oneTimeAction.getValue(Action.NAME));
        table.getActionMap().put(
                periodicAction.getValue(Action.NAME),
                periodicAction);
        table.getInputMap().put(
                (KeyStroke) periodicAction.getValue(Action.ACCELERATOR_KEY),
                periodicAction.getValue(Action.NAME));
        JScrollPane scrollPane = new JScrollPane(table,
                ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS,
                ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        frame.add(scrollPane, BorderLayout.CENTER);

        tablePopup = new JPopupMenu();
        tablePopup.add(oneTimeAction);
        tablePopup.add(periodicAction);
    }

    private void maybeShowPopup(MouseEvent e) {
        if (e.isPopupTrigger()) {
            int row = table.rowAtPoint(e.getPoint());
            table.clearSelection();
            table.addRowSelectionInterval(row, row);
            Amortization amt = tableModel.get(row);
            if (amt != null && row < tableModel.size() - 1)
                tablePopup.show(e.getComponent(), e.getX(), e.getY());
        }
    }

    private void doOneTimePayment() {
        int row = table.getSelectedRow();
        Amortization amt = tableModel.get(row);
        if (amt != null && amt.paymentNumber != 0
        &&  row < tableModel.size() - 1) {

            NumberFormat moneyFormat = NumberFormat.getInstance();
            moneyFormat.setMaximumFractionDigits(2);
            moneyFormat.setMinimumFractionDigits(2);

            NumberFormatter paymentFormat =
                    new NumberFormatter(moneyFormat);
            paymentFormat.setValueClass(Double.class);
            paymentFormat.setMinimum(Double.valueOf(0.01));

            final JFormattedTextField paymentField =
                    new JFormattedTextField(paymentFormat);

            String[] messageStrings = {
                    "Enter the additional amount to pay after payment " +
                    amt.paymentNumber
            };
            Object[] message = new Object[2];
            message[0] = new JLabel(messageStrings[0]);
            message[1] = paymentField;

            JOptionPane pane = new JOptionPane(message,
                    JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION,
                    null, null, null);
            JDialog dialog = pane.createDialog(frame,
                    "One Time Principal Payment");
            dialog.addComponentListener(new ComponentAdapter() {
                @Override
                public void componentShown(ComponentEvent e) {
                    paymentField.requestFocusInWindow();
                }
            });
            pane.selectInitialValue();
            dialog.setVisible(true);
            dialog.dispose();
            Integer result = (Integer) pane.getValue();
            // 2 for cancel, -1 for ESC, 0 is OK, null when closed
            if (result != null && result == 0) {
                Double payment = (Double) paymentField.getValue();
                if (payment != null)
                    oneTimePayment(row, payment, amt);
                else {
                    JOptionPane.showMessageDialog(frame,
                            "Please enter a payment amount", "Error",
                            JOptionPane.ERROR_MESSAGE);
                    doOneTimePayment();
                }
            }
        }
        if (amt.paymentNumber == 0) {
            JOptionPane.showMessageDialog(frame, new String[] {
                    "A one-time payment cannot be scheduled",
                    "after another one-time payment." },
                    "Warning", JOptionPane.WARNING_MESSAGE);
        }
    }
    private void oneTimePayment(int row, double payment, Amortization amt) {
        Amortization p = new Amortization();
        p.paymentNumber = 0;
        p.iPaid = 0;
        p.pPaid = payment;
        p.pRemaining = amt.pRemaining - payment;
        p.interestPaid = amt.interestPaid;
        p.equity = amt.equity + p.pPaid;
        tableModel.insert(row + 1, p);
        tableModel.removeAfter(row + 1);
        _doAmortization(amt.paymentNumber + 1, p.pRemaining,
                p.interestPaid, p.equity);
    }

    private void startPeriodicPayments() {
        int row = table.getSelectedRow();
        Amortization amt = tableModel.get(row);
        if (amt != null && amt.paymentNumber != 0
        &&  row < tableModel.size() - 1) {
            NumberFormat moneyFormat = NumberFormat.getInstance();
            moneyFormat.setMaximumFractionDigits(2);
            moneyFormat.setMinimumFractionDigits(2);

            NumberFormatter paymentFormat =
                    new NumberFormatter(moneyFormat);
            paymentFormat.setValueClass(Double.class);
            paymentFormat.setMinimum(Double.valueOf(0.01));

            final JFormattedTextField paymentField =
                    new JFormattedTextField(paymentFormat);
            paymentField.setColumns(10);

            NumberFormatter periodFormat =
                    new NumberFormatter(NumberFormat.getIntegerInstance());
            periodFormat.setValueClass(Integer.class);
            periodFormat.setMaximum(Integer.valueOf(3650));
            periodFormat.setMinimum(Integer.valueOf(1));

            JFormattedTextField periodField =
                    new JFormattedTextField(periodFormat);
            periodField.setColumns(3);

            JFormattedTextField nField = new JFormattedTextField(periodFormat);
            nField.setColumns(3);
            JPanel panel = new JPanel();
            panel.add(new JLabel("Make a payment of"));
            panel.add(paymentField);
            panel.add(new JLabel("every"));
            panel.add(periodField);
            panel.add(new JLabel("payments"));
            Object[] message = new Object[2];
            message[0] = panel;
            panel = new JPanel();
            panel.add(new JLabel("starting after payment " +
                    amt.paymentNumber + " for "));
            panel.add(nField);
            panel.add(new JLabel("payments"));
            message[1] = panel;
            JOptionPane pane = new JOptionPane(message,
                    JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION,
                    null, null, null);
            JDialog dialog = pane.createDialog(frame,
                    "Periodic Principal Payments");
            dialog.addComponentListener(new ComponentAdapter() {
                @Override
                public void componentShown(ComponentEvent e) {
                    paymentField.requestFocusInWindow();
                }
            });
            pane.selectInitialValue();
            dialog.setVisible(true);
            dialog.dispose();
            Integer result = (Integer) pane.getValue();
            // 2 for cancel, -1 for ESC, 0 is OK, null when closed
            if (result != null && result == 0) {
                Integer period = (Integer) periodField.getValue();
                Double payment = (Double) paymentField.getValue();
                Integer n = (Integer) nField.getValue();
                if (period != null && payment != null) {
                    int np = n == null ? Integer.MAX_VALUE : n;
                    for (int i = 0; i < np && tableModel.size() > row; i++) {
                        amt = tableModel.get(row);
                        oneTimePayment(row, payment, amt);
                        row += period + 1; // account for OTP
                    }
                } else {
                    JOptionPane.showMessageDialog(frame,
                            "Please enter a payment amount and period",
                            "Error", JOptionPane.ERROR_MESSAGE);
                    startPeriodicPayments();
                }
            }
        }
        if (amt.paymentNumber == 0) {
            JOptionPane.showMessageDialog(frame, new String[] {
                    "Periodic payments cannot be scheduled",
                    "to start after a one-time payment." },
                    "Warning", JOptionPane.WARNING_MESSAGE);
        }
    }

    private void setColumnSizes() {
        TableColumn column = null;
        Component comp = null;
        int headerWidth = 0;
        int cellWidth = 0;
        TableCellRenderer headerRenderer =
            table.getTableHeader().getDefaultRenderer();

        for (int i = 0; i < 5; i++) {
            int row;
            switch (i) {
            case 0:
            case 3:
            case 4:
            case 5:
                row = tableModel.size() - 1;
                break;
            case 1:
            case 2:
            default:
                row = 0;
            }
            column = table.getColumnModel().getColumn(i);

            comp = headerRenderer.getTableCellRendererComponent(
                                 null, column.getHeaderValue(),
                                 false, false, 0, 0);
            headerWidth = comp.getPreferredSize().width;

            comp = table.getDefaultRenderer(tableModel.getColumnClass(i)).
                    getTableCellRendererComponent(table,
                            tableModel.getColumnValue(tableModel.get(row), i),
                            false, false, 0, i);
            cellWidth = comp.getPreferredSize().width;

            column.setPreferredWidth(Math.max(headerWidth, cellWidth));
        }
    }

    private JPanel newLabelledPanel(String label, JFormattedTextField t) {
        JPanel panel = new JPanel();
        panel.setLayout(new GridLayout(2, 1));
        panel.add(new JLabel(label));
        panel.add(t);
        return panel;
    }

    private void layoutMenu() {
        JMenuBar bar = new JMenuBar();
        JMenu fileMenu = new JMenu("File");
        JMenuItem item;

        fileMenu.setMnemonic('f');

        item = new JMenuItem("New Window");
        item.setMnemonic('n');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                TVM tvm = new TVM();
                tvm.getFrame().setVisible(true);
            }
        });
        fileMenu.add(item);
        item = new JMenuItem("Print");
        item.setMnemonic('p');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                doPrint();
            }
        });
        fileMenu.add(item);

        item = new JMenuItem("Exit");
        item.setMnemonic('x');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                frame.setVisible(false);
                frame.dispose();
                System.exit(0);
            }
        });
        fileMenu.add(item);

        bar.add(fileMenu);
        JMenu calcMenu = new JMenu("Calculate");
        calcMenu.setMnemonic('u');
        item = new JMenuItem("Amortize");
        item.setMnemonic('m');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                doAmortization();
            }
        });
        calcMenu.add(item);

        item = new JMenuItem("Payment");
        item.setMnemonic('a');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                doCalculatePayment();
            }
        });
        calcMenu.add(item);

        item = new JMenuItem("Principal");
        item.setMnemonic('p');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                doCalculatePrincipal();
            }
        });
        calcMenu.add(item);

        item = new JMenuItem("Interest Rate");
        item.setMnemonic('i');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                doCalculateInterest();
            }
        });
        calcMenu.add(item);

        item = new JMenuItem("Payments");
        item.setMnemonic('n');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                doCalculatePeriods();
            }
        });
        calcMenu.add(item);
        bar.add(calcMenu);

        JMenu helpMenu = new JMenu("Help");
        helpMenu.setMnemonic('h');
        item = new JMenuItem("About");
        item.setMnemonic('a');
        item.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                String[] messageStrings = {
                        "Amortization Calculator",
                        "Copyright 2007, 2008 Perry Nguyen <pfnguyen@hanhuy.com>",
                        "Licensed under the Apache License, Version 2.0",
                };
                Object[] message = new Object[messageStrings.length];
                for (int i = 0, j = message.length; i < j; i++)
                    message[i] = new JLabel(messageStrings[i],
                             SwingConstants.CENTER);

                JOptionPane.showMessageDialog(frame, message, "About",
                        JOptionPane.PLAIN_MESSAGE);
            }
        });
        helpMenu.add(item);
        bar.add(helpMenu);
        frame.setJMenuBar(bar);
    }

    private void center() {
        frame.pack();
        Toolkit tk = Toolkit.getDefaultToolkit();
        Dimension dim = tk.getScreenSize();
        Dimension dim2 = frame.getSize();
        int xPos = (dim.width  - dim2.width)  / 2;
        int yPos = (dim.height - dim2.height) / 2;
        frame.setLocation(xPos, yPos);
    }

    private static class FieldValues {
        BigDecimal principal;
        BigDecimal interest;
        BigDecimal payment;
        int periods = DEFAULT_PERIODS;
        int periodsYear = DEFAULT_PER_YEAR;
    }

    private FieldValues getFieldValues() {
        FieldValues fv = new FieldValues();

        Double d;
        Integer i;
        try {
            principalField.commitEdit();
        }
        catch (ParseException e) { }
        try {
            interestField.commitEdit();
        }
        catch (ParseException e) { }
        try {
            paymentField.commitEdit();
        }
        catch (ParseException e) { }
        try {
            paymentsField.commitEdit();
        }
        catch (ParseException e) { }
        try {
            paymentsPerYearField.commitEdit();
        }
        catch (ParseException e) { }
        d = (Double) principalField.getValue();
        if (d != null)
            fv.principal = new BigDecimal(d);
        d = (Double) interestField.getValue();
        if (d != null)
            fv.interest = new BigDecimal(d);
        d = (Double) paymentField.getValue();
        if (d != null)
            fv.payment = new BigDecimal(d);
        i = (Integer) paymentsField.getValue();
        if (i != null)
            fv.periods = i;
        i = (Integer) paymentsPerYearField.getValue();
        if (i != null)
            fv.periodsYear = i;
        return fv;
    }

    private static class Amortization {
        int paymentNumber;
        double iPaid;
        double pPaid;
        double pRemaining;
        double interestPaid;
        double payment;
        double equity;
    }

    private void doPrint() {
        if (tableModel.size() < 1) {
            JOptionPane.showMessageDialog(frame,
                    "There are no amortization values to print",
                    "Nothing to print", JOptionPane.INFORMATION_MESSAGE);
            return;
        }
        FieldValues fv = getFieldValues();
        Amortization last = tableModel.get(tableModel.size() - 1);
        String h = MessageFormat.format("Page '{0}' - " +
                "Principal:  {0,number,currency}  " +
                "APR:  {1,number,0.00}%  " +
                "Payments:  {2,number,currency}  " +
                "Final Payment:  {3,number,currency}",
                new Object[] {
                    fv.principal,
                    fv.interest,
                    fv.payment,
                    last.pPaid + last.iPaid,
                });
        final MessageFormat header = new MessageFormat(h);
        PrinterJob job = PrinterJob.getPrinterJob();
        final Printable tp = table.getPrintable(JTable.PrintMode.FIT_WIDTH,
                null, null);
        Printable p = new Printable() {
            public int print(Graphics g, PageFormat pf, int page)
            throws PrinterException {
                StringBuffer b = new StringBuffer();
                String h = header.format(
                        new Object[] { page + 1 }, b, null).toString();
                g.drawString(h, 72 / 4, 72 / 3);
                ((Graphics2D)g).translate(0, 72 / 2);
                return tp.print(g, new PFWrapper(pf), page);
            }
        };
        job.setPrintable(p);
        job.setJobName("Amortization Table");
        try {
            if (job.printDialog())
                job.print();
        }
        catch (PrinterException e) {
            JOptionPane.showMessageDialog(frame, e.getMessage(), "Print Error",
                    JOptionPane.ERROR_MESSAGE);
        }
    }
    private static class PFWrapper extends PageFormat {
        private PageFormat pf;
        PFWrapper(PageFormat pf) {
            this.pf = pf;
        }
        @Override
        public Object clone() { return pf.clone(); }
        @Override
        public double getHeight() { return pf.getHeight(); }
        @Override
        public double getImageableHeight() {
            return pf.getImageableHeight() - 72 / 2;
        }
        @Override
        public double getImageableWidth() { return pf.getImageableWidth(); }
        @Override
        public double getImageableX() { return pf.getImageableX(); }
        @Override
        public double getImageableY() { return pf.getImageableY(); }
        @Override
        public double[] getMatrix() { return pf.getMatrix(); }
        @Override
        public int getOrientation() { return pf.getOrientation(); }
        @Override
        public Paper getPaper() { return pf.getPaper(); }
        @Override
        public double getWidth() { return pf.getWidth(); }
        @Override
        public void setOrientation(int o) { pf.setOrientation(o); }
        @Override
        public void setPaper(Paper p) { pf.setPaper(p); }
    }

    private void doAmortization() {
        FieldValues fv = getFieldValues();
        if (fv.principal != null && fv.interest != null
        &&  fv.payment != null) {
            tableModel.clear();

            double principal = fv.principal.doubleValue();
            _doAmortization(1, principal, 0, 0);

        } else
            showError();
    }

    private void _doAmortization(int start, double principal,
            double totalInterest, double totalPrincipal) {
        FieldValues fv = getFieldValues();
        if (fv.principal != null && fv.interest != null
        &&  fv.payment != null) {

            double rate = getPeriodRate(
                    fv.interest, fv.periodsYear).doubleValue();

            for (int n = start; principal > 0; n++) {

                Amortization amt = new Amortization();
                amt.paymentNumber = n;
                amt.iPaid = rate * principal;
                amt.pPaid = fv.payment.doubleValue() - amt.iPaid;

                if (amt.pPaid > principal)
                    amt.pPaid = principal;

                totalInterest += amt.iPaid;
                totalPrincipal += amt.pPaid;
                amt.interestPaid = totalInterest;
                principal = principal - amt.pPaid;
                amt.pRemaining = principal;
                amt.equity = totalPrincipal;

                tableModel.add(amt);
            }
        } else
            showError();
        setColumnSizes();
    }

    private void doCalculateInterest() {
        FieldValues fv = getFieldValues();
        if (fv.principal != null && fv.payment != null) {
            BigDecimal result = calculateInterest(fv.periods,
                    fv.principal, fv.periodsYear, fv.payment);
            interestField.setValue(result.doubleValue());
        } else
            showError();
    }

    private void doCalculatePayment() {
        FieldValues fv = getFieldValues();
        if (fv.principal != null && fv.interest != null) {
            BigDecimal result = calculatePayment(fv.periods,
                    fv.principal, fv.interest, fv.periodsYear);
            paymentField.setValue(result.doubleValue());
        } else
            showError();
    }

    private void doCalculatePeriods() {
        FieldValues fv = getFieldValues();
        if (fv.principal != null && fv.interest != null
        &&  fv.payment != null) {
            int result = calculatePeriods(
                    fv.principal, fv.interest, fv.periodsYear, fv.payment);
            paymentsField.setValue(result);
        } else
            showError();
    }

    private void doCalculatePrincipal() {
        FieldValues fv = getFieldValues();
        if (fv.payment != null && fv.interest != null) {
            BigDecimal result = calculatePrincipal(fv.periods,
                    fv.interest, fv.periodsYear, fv.payment);
            principalField.setValue(result.doubleValue());
        } else
            showError();
    }

    private void showError() {
        JOptionPane.showMessageDialog(frame,
                "Required values have not been set", "Input Required",
                JOptionPane.ERROR_MESSAGE);
    }

    public JFrame getFrame() {
        return frame;
    }

    private static class TableModel extends AbstractTableModel {
        final List<Amortization> list = new ArrayList<Amortization>();
        final NumberFormat fmt = NumberFormat.getCurrencyInstance();
        final static String[] labels = { "#", "Interest Paid",
                "Principal Paid", "Principal Remaining",
                "Total Interest Paid", "Equity" };
        public int getRowCount() { return list.size(); }
        public int getColumnCount() { return labels.length; }
        public Object getValueAt(int row, int column) {
            return getColumnValue(list.get(row), column);
        }
        @Override
        public String getColumnName(int column) {
            return labels[column];
        }
        public void clear() {
            list.clear();
            if (list.size() > 0)
                fireTableRowsDeleted(0, list.size() - 1);
        }
        public Amortization get(int index) {
            return list.get(index);
        }
        public int size() {
            return list.size();
        }
        public void add(Amortization amt) {
            list.add(amt);
            fireTableRowsInserted(list.size() - 1, list.size() - 1);
        }
        public void insert(int row, Amortization amt) {
            list.add(row, amt);
            fireTableRowsInserted(row, row);
        }
        public void removeAfter(int row) {
            int size = list.size();
            while (list.size() > row + 1) {
                list.remove(row + 1);
            }
            fireTableRowsDeleted(row + 1, size - 1);
        }
        private Object getColumnValue(Amortization amt, int column) {
            switch (column) {
            case 0:
                return amt.paymentNumber;
            case 1:
                return fmt.format(amt.iPaid);
            case 2:
                return fmt.format(amt.pPaid);
            case 3:
                return fmt.format(amt.pRemaining);
            case 4:
                return fmt.format(amt.interestPaid);
            case 5:
                return fmt.format(amt.equity);
            default:
                return "UNKNOWN";
            }
        }
    }

    /**
     * Basic amortization equations:
     * <pre>
     * A = P * ( (i * (1 + i)^n) / ((1 + i)^n - 1) )
     * A = (P * i) / (1 - (1 + i)^(-n))
     * </pre>
     */
    public static BigDecimal calculatePayment(int nPayments,
            BigDecimal pPrincipal, BigDecimal iInterest, int pPeriodsYear) {
        BigDecimal periodRate = getPeriodRate(iInterest, pPeriodsYear);
        BigDecimal top = pPrincipal.multiply(periodRate);
        BigDecimal bottom = ONE.subtract(
                ONE.divide(
                ONE.add(periodRate).pow(nPayments), mc));
        return top.divide(bottom, 2, ROUND_UP);
    }

    /**
     * Solve for n:
     * n = -(log(1 - (P * i)/A) / log(1 + i))
     */
    public static int calculatePeriods(BigDecimal pPrincipal,
            BigDecimal iInterest, int pPeriodsYear, BigDecimal aPayments) {
        BigDecimal periodRate = getPeriodRate(iInterest, pPeriodsYear);
        BigDecimal innerTop = pPrincipal.multiply(periodRate).divide(
                aPayments, mc);
        BigDecimal top = ONE.subtract(innerTop);
        BigDecimal bottom = ONE.add(periodRate);
        return -1 * (int) Math.round(
                Math.log(top.doubleValue()) / Math.log(bottom.doubleValue()));
    }

    private static BigDecimal getPeriodRate(BigDecimal iInterest,
            int pPeriodsYear) {
        iInterest = iInterest.divide(BigDecimal.valueOf(100), mc);
        return iInterest.divide(
                new BigDecimal(pPeriodsYear), mc);
    }

    /**
     * Solve for P:
     * P = (A * (1 - (1 + i)^(-n)) / i)
     */
    public static BigDecimal calculatePrincipal(int nPayments,
            BigDecimal iInterest, int pPeriodsYear, BigDecimal aPayments) {
        BigDecimal periodRate = getPeriodRate(iInterest, pPeriodsYear);

        BigDecimal bottom = ONE.subtract(
                ONE.divide(ONE.add(periodRate).pow(nPayments), mc));

        return aPayments.multiply(bottom).divide(
                periodRate, 2, ROUND_HALF_UP);
    }

    /**
     * Approximate for i, since i is not solvable in the above.  We will
     * use Newton-Raphson approximation.
     * <p>
     * These following equations are taking the above amortization formula,
     * and solving for zero, then taking the derivative to achieve f'(x)
     * </p>
     * <pre>
     * f(x) =  (P*i*((1 + i)^n)) - (A*(((1 + i)^n) - 1))
     * Simplified:
     * f(x) = (((1 + i)^n)*((P*i) - A)) + A
     *
     *                          n*((P*i) - A)
     * f'(x) = ((1 + i)^n)*(P + -------------)
     *                             (1 + i)
     * </pre>
     * Newton-Raphson approximation is of the form:
     * <pre>
     *                   f(x[y])
     * x[y+1] = x[y] - ----------
     *                  f'(x[y])
     * </pre>
     * We repeatedly calculate this until the difference between x[y+1] and
     * x[y] is within margin.
     */
    public static BigDecimal calculateInterest(int nPayments,
            BigDecimal pPrincipal, int pPeriodsYear, BigDecimal aPayments) {
        for (BigDecimal estimate = ESTIMATE_START;
             estimate.compareTo(ESTIMATE_END) == -1;
             estimate = estimate.add(ESTIMATE_INCR)) {

            BigDecimal newEstimate = doRNInterestApproximation(estimate,
                    estimate, nPayments, pPrincipal, aPayments);
            if (newEstimate.compareTo(ZERO) > 0)
                return newEstimate.multiply(
                        BigDecimal.valueOf(pPeriodsYear))
                                .multiply(BigDecimal.valueOf(100), mc);
        }
        return ZERO;
    }

    private static BigDecimal doRNInterestApproximation(
            BigDecimal estimate, BigDecimal previous,
            int nPayments, BigDecimal pPrincipal, BigDecimal aPayments) {
        if (estimate.compareTo(ZERO) == 1
        && estimate.subtract(previous).abs()
                .compareTo(ESTIMATE_DIVERGE) == -1) {

            previous = estimate;
            // doing this math in BigDecimal is *slow*
            // since this is an approximation, I guess doing it with
            // double precision isn't all bad...
            double f = (Math.pow(1 + estimate.doubleValue(), nPayments) *
                    (pPrincipal.doubleValue() *
                      estimate.doubleValue() -
                      aPayments.doubleValue())) + aPayments.doubleValue();
            double fPrime = Math.pow(1 + estimate.doubleValue(), nPayments) *
                    ((nPayments * pPrincipal.doubleValue() *
                      estimate.doubleValue() - aPayments.doubleValue()) /
                    (1 + estimate.doubleValue()) + pPrincipal.doubleValue());

            if (fPrime == 0)
                return BigDecimal.valueOf(-1);

            estimate = new BigDecimal(estimate.doubleValue() - f/fPrime);

            if (estimate.subtract(previous).abs().compareTo(ESTIMATE_MARGIN)
            == -1) {
                return estimate;
            }

            return doRNInterestApproximation(estimate, previous, nPayments,
                                      pPrincipal, aPayments);
        }
        return BigDecimal.valueOf(-1);
    }
}

