/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 * 
 * Copyright 2008 jOpenDocument, by ILM Informatique. All rights reserved.
 * 
 * The contents of this file are subject to the terms of the GNU
 * General Public License Version 3 only ("GPL").  
 * You may not use this file except in compliance with the License. 
 * You can obtain a copy of the License at http://www.gnu.org/licenses/gpl-3.0.html
 * See the License for the specific language governing permissions and limitations under the License.
 * 
 * When distributing the software, include this License Header Notice in each file.
 * 
 */

package org.jopendocument.dom.spreadsheet;

import org.jopendocument.dom.XMLVersion;
import org.jopendocument.dom.ODDocument;
import org.jopendocument.dom.spreadsheet.SheetTableModel.MutableTableModel;
import org.jopendocument.util.CollectionUtils;
import org.jopendocument.util.Tuple2;
import org.jopendocument.util.JDOMUtils;

import java.awt.Point;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import java.util.regex.Matcher;

import javax.swing.table.TableModel;

import org.jdom.Attribute;
import org.jdom.Element;

/**
 * A single sheet in a spreadsheet.
 * 
 * @author Sylvain
 * @param <D> type of table parent
 */
public class Table<D extends ODDocument> extends TableCalcNode<TableStyle, D> {

    static Element createEmpty(XMLVersion ns) {
        // from the relaxNG : a table must have at least one cell
        final Element col = Column.createEmpty(ns, null);
        final Element row = Row.createEmpty(ns).addContent(Cell.createEmpty(ns));
        return new Element("table", ns.getTABLE()).addContent(col).addContent(row);
    }

    static final String getName(final Element elem) {
        return elem.getAttributeValue("name", elem.getNamespace("table"));
    }

    // ATTN Row have their index as attribute
    private final List<Row<D>> rows;
    private int headerRowCount;
    private final List<Column<D>> cols;
    private int headerColumnCount;

    public Table(D parent, Element local) {
        super(parent, local, TableStyle.class);

        this.rows = new ArrayList<Row<D>>();
        this.cols = new ArrayList<Column<D>>();

        this.readColumns();
        this.readRows();
    }

    private void readColumns() {
        this.read(true);
    }

    private final void readRows() {
        this.read(false);
    }

    private final void read(final boolean col) {
        final Tuple2<List<Element>, Integer> r = flatten(col);
        (col ? this.cols : this.rows).clear();
        for (final Element clone : r.get0()) {
            if (col)
                this.addCol(clone);
            else
                this.addRow(clone);
        }
        if (col)
            this.headerColumnCount = r.get1();
        else
            this.headerRowCount = r.get1();
    }

    private final void addCol(Element clone) {
        this.cols.add(new Column<D>(this, clone));
    }

    private Tuple2<List<Element>, Integer> flatten(boolean col) {
        final List<Element> res = new ArrayList<Element>();
        final Element header = this.getElement().getChild("table-header-" + getName(col) + "s", getTABLE());
        if (header != null)
            res.addAll(flatten(header, col));
        final int headerCount = res.size();

        res.addAll(flatten(getElement(), col));

        return Tuple2.create(res, headerCount);
    }

    @SuppressWarnings("unchecked")
    private List<Element> flatten(final Element elem, boolean col) {
        final String childName = getName(col);
        final List<Element> children = elem.getChildren("table-" + childName, getTABLE());
        // not final, since iter.add() does not work consistently, and
        // thus we must recreate an iterator each time
        ListIterator<Element> iter = children.listIterator();
        while (iter.hasNext()) {
            final Element row = iter.next();
            final Attribute repeatedAttr = row.getAttribute("number-" + childName + "s-repeated", getTABLE());
            if (repeatedAttr != null) {
                row.removeAttribute(repeatedAttr);
                final int index = iter.previousIndex();
                int repeated = Integer.parseInt(repeatedAttr.getValue());
                if (repeated > 60000) {
                    repeated = 10;
                }
                // -1 : we keep the original row
                for (int i = 0; i < repeated - 1; i++) {
                    final Element clone = (Element) row.clone();
                    // cannot use iter.add() since on JDOM 1.1 if row is the last table-column
                    // before table-row the clone is added at the very end
                    children.add(index, clone);
                }
                // restart after the added rows
                iter = children.listIterator(index + repeated);
            }
        }

        return children;
    }

    public final String getName() {
        return getName(this.getElement());
    }

    public final void setName(String name) {
        this.getElement().setAttribute("name", name, this.getODDocument().getVersion().getTABLE());
    }

    public void detach() {
        this.getElement().detach();
    }

    private final String getName(boolean col) {
        return col ? "column" : "row";
    }

    public final Object getPrintRanges() {
        return this.getElement().getAttributeValue("print-ranges", this.getTABLE());
    }

    public final void setPrintRanges(String s) {
        this.getElement().setAttribute("print-ranges", s, this.getTABLE());
    }

    public final void removePrintRanges() {
        this.getElement().removeAttribute("print-ranges", this.getTABLE());
    }

    public final synchronized void duplicateFirstRows(int nbFirstRows, int nbDuplicate) {
        this.duplicateRows(0, nbFirstRows, nbDuplicate);
    }

    public final synchronized void insertDuplicatedRows(int rowDuplicated, int nbDuplicate) {
        this.duplicateRows(rowDuplicated, 1, nbDuplicate);
    }

    /**
     * Clone a range of rows. Eg if you want to copy once rows 2 through 5, you call
     * <code>duplicateRows(2, 4, 1)</code>.
     * 
     * @param start the first row to clone.
     * @param count the number of rows after <code>start</code> to clone.
     * @param copies the number of copies of the range to make.
     */
    public final synchronized void duplicateRows(int start, int count, int copies) {
        final int stop = start + count;
        // clone xml elements and add them to our tree
        final List<Element> clones = new ArrayList<Element>(count * copies);
        for (int i = 0; i < copies; i++) {
            for (int l = start; l < stop; l++) {
                final Element r = this.rows.get(l).getElement();
                clones.add((Element) r.clone());
            }
        }
        // works anywhere its XML element is
        JDOMUtils.insertAfter(this.rows.get(stop - 1).getElement(), clones);

        // synchronize our rows with our new tree
        this.readRows();
    }

    private synchronized void addRow(Element child) {
        this.rows.add(new Row<D>(this, child, this.rows.size()));
    }

    public final Point resolveHint(String ref) {
        final Point res = resolve(ref);
        if (res != null) {
            return res;
        } else
            throw new IllegalArgumentException(ref + " is not a cell ref, if it's a named range, you must use it on a SpreadSheet.");
    }

    // *** set cell

    public final boolean isCellValid(int x, int y) {
        if (x > this.getColumnCount())
            return false;
        else if (y > this.getRowCount())
            return false;
        else
            return this.getImmutableCellAt(x, y).isValid();
    }

    public final MutableCell<D> getCellAt(int x, int y) {
        return this.getRow(y).getMutableCellAt(x);
    }

    public final MutableCell<D> getCellAt(String ref) {
        return this.getCellAt(resolveHint(ref));
    }

    final MutableCell<D> getCellAt(Point p) {
        return this.getCellAt(p.x, p.y);
    }

    /**
     * Sets the value at the specified coordinates.
     * 
     * @param val the new value, <code>null</code> will be treated as "".
     * @param x the column.
     * @param y the row.
     */
    public final void setValueAt(Object val, int x, int y) {
        if (val == null)
            val = "";
        // ne pas casser les repeated pour rien
        if (!val.equals(this.getValueAt(x, y)))
            this.getCellAt(x, y).setValue(val);
    }

    // *** get cell

    protected final Cell<D> getImmutableCellAt(int x, int y) {
        return this.getRow(y).getCellAt(x);
    }

    protected final Cell<D> getImmutableCellAt(String ref) {
        final Point p = resolveHint(ref);
        return this.getImmutableCellAt(p.x, p.y);
    }

    /**
     * @param row la ligne (0 a lineCount-1)
     * @param column la colonnee (0 a colonneCount-1)
     * @return la valeur de la cellule spécifiée.
     */
    public final Object getValueAt(int column, int row) {
        return this.getImmutableCellAt(column, row).getValue();
    }

    /**
     * Find the style name for the specified cell.
     * 
     * @param column column index.
     * @param row row index.
     * @return the style name, can be <code>null</code>.
     */
    public final String getStyleNameAt(int column, int row) {
        // first the cell
        String cellStyle = this.getImmutableCellAt(column, row).getStyleAttr();
        if (cellStyle != null)
            return cellStyle;
        // then the row (as specified in §2 of section 8.1)
        cellStyle = this.getRow(row).getElement().getAttributeValue("default-cell-style-name", getTABLE());
        if (cellStyle != null)
            return cellStyle;
        // and finally the column
        return this.getColumn(column).getElement().getAttributeValue("default-cell-style-name", getTABLE());
    }

    public final CellStyle getStyleAt(int column, int row) {
        return CellStyle.DESC.findStyle(this.getODDocument().getPackage(), this.getElement().getDocument(), this.getStyleNameAt(column, row));
    }

    /**
     * Return the coordinates of cells using the passed style.
     * 
     * @param cellStyleName a style name.
     * @return the cells using <code>cellStyleName</code>.
     */
    public final List<Tuple2<Integer, Integer>> getStyleReferences(final String cellStyleName) {
        final List<Tuple2<Integer, Integer>> res = new ArrayList<Tuple2<Integer, Integer>>();
        final Set<Integer> cols = new HashSet<Integer>();
        final int columnCount = getColumnCount();
        for (int i = 0; i < columnCount; i++) {
            if (cellStyleName.equals(this.getColumn(i).getElement().getAttributeValue("default-cell-style-name", getTABLE())))
                cols.add(i);
        }
        final int rowCount = getRowCount();
        for (int y = 0; y < rowCount; y++) {
            final Row<D> row = this.getRow(y);
            final String rowStyle = row.getElement().getAttributeValue("default-cell-style-name", getTABLE());
            for (int x = 0; x < columnCount; x++) {
                final String cellStyle = row.getCellAt(x).getStyleAttr();
                final boolean match;
                // first the cell
                if (cellStyle != null)
                    match = cellStyleName.equals(cellStyle);
                // then the row (as specified in §2 of section 8.1)
                else if (rowStyle != null)
                    match = cellStyleName.equals(rowStyle);
                // and finally the column
                else
                    match = cols.contains(x);

                if (match)
                    res.add(Tuple2.create(x, y));
            }
        }
        return res;
    }

    /**
     * Retourne la valeur de la cellule spécifiée.
     * 
     * @param ref une référence de la forme "A3".
     * @return la valeur de la cellule spécifiée.
     */
    public final Object getValueAt(String ref) {
        return this.getImmutableCellAt(ref).getValue();
    }

    // *** get count

    private Row<D> getRow(int index) {
        return this.rows.get(index);
    }

    public final Column<D> getColumn(int i) {
        return this.cols.get(i);
    }

    public final int getRowCount() {
        return this.rows.size();
    }

    public final int getHeaderRowCount() {
        return this.headerRowCount;
    }

    public final int getColumnCount() {
        return this.cols.size();
    }

    public final int getHeaderColumnCount() {
        return this.headerColumnCount;
    }

    // *** set count

    /**
     * Changes the column count without keeping the table width.
     * 
     * @param newSize the new column count.
     * @see #setColumnCount(int, int, boolean)
     */
    public final void setColumnCount(int newSize) {
        this.setColumnCount(newSize, -1, false);
    }

    /**
     * Assure that this sheet has at least <code>newSize</code> columns.
     * 
     * @param newSize the minimum column count this table should have.
     */
    public final void ensureColumnCount(int newSize) {
        if (newSize > this.getColumnCount())
            this.setColumnCount(newSize);
    }

    /**
     * Changes the column count. If <code>newSize</code> is less than {@link #getColumnCount()}
     * extra cells will be chopped off. Otherwise empty cells will be created.
     * 
     * @param newSize the new column count.
     * @param colIndex the index of the column to be copied, -1 for empty column (i.e. default
     *        style).
     * @param keepTableWidth <code>true</code> if the table should be same width after the column
     *        change.
     */
    public final void setColumnCount(int newSize, int colIndex, final boolean keepTableWidth) {
        final int toGrow = newSize - this.getColumnCount();
        if (toGrow < 0) {
            this.removeColumn(newSize, this.getColumnCount(), keepTableWidth);
        } else if (toGrow > 0) {
            // the list of columns cannot be mixed with other elements
            // so just keep adding after the last one
            final int indexOfLastCol;
            if (this.getColumnCount() == 0)
                // from section 8.1.1 the only possible elements after cols are rows
                // but there can't be rows w/o columns, so just add to the end
                indexOfLastCol = this.getElement().getContentSize() - 1;
            else
                indexOfLastCol = this.getElement().getContent().indexOf(this.getColumn(this.getColumnCount() - 1).getElement());

            final Element elemToClone;
            if (colIndex < 0) {
                elemToClone = Column.createEmpty(getODDocument().getVersion(), this.createDefaultColStyle());
            } else {
                elemToClone = getColumn(colIndex).getElement();
            }
            for (int i = 0; i < toGrow; i++) {
                final Element newElem = (Element) elemToClone.clone();
                this.getElement().addContent(indexOfLastCol + 1 + i, newElem);
                this.cols.add(new Column<D>(this, newElem));
            }
            // now update widths
            updateWidth(keepTableWidth);

            // add needed cells
            for (final Row r : this.rows) {
                r.columnCountChanged();
            }
        }
    }

    public final void removeColumn(int colIndex, final boolean keepTableWidth) {
        this.removeColumn(colIndex, colIndex + 1, keepTableWidth);
    }

    /**
     * Remove columns from this. As with OpenOffice, no cell must be covered in the column to
     * remove. ATTN <code>keepTableWidth</code> only works for tables in text document that are not
     * aligned automatically (ie fill the entire page). ATTN spreadsheet applications may hide from
     * you the real width of sheets, eg display only columns A to AJ when in reality there's
     * hundreds of blank columns beyond. Thus if you pass <code>true</code> to
     * <code>keepTableWidth</code> you'll end up with huge widths.
     * 
     * @param firstIndex the first column to remove.
     * @param lastIndex the last column to remove, exclusive.
     * @param keepTableWidth <code>true</code> if the table should be same width after the column
     *        change.
     */
    public final void removeColumn(int firstIndex, int lastIndex, final boolean keepTableWidth) {
        // first check that removeCells() will succeed, so that we avoid an incoherent XML state
        for (final Row r : this.rows) {
            r.checkRemove(firstIndex, lastIndex);
        }
        // rm column element
        remove(true, firstIndex, lastIndex - 1);
        // update widths
        updateWidth(keepTableWidth);
        // rm cells
        for (final Row r : this.rows) {
            r.removeCells(firstIndex, lastIndex);
        }
    }

    private void updateWidth(final boolean keepTableWidth) {
        final Float currentWidth = getWidth();
        float newWidth = 0;
        // columns are flattened in ctor: no repeated
        for (final Column col : this.cols) {
            newWidth += col.getWidth();
        }
        // remove all rel-column-width, simpler and Spreadsheet doesn't use them
        // SpreadSheets have no table width
        if (keepTableWidth && currentWidth != null) {
            // compute column-width from table width
            final float ratio = currentWidth / newWidth;
            // once per style not once per col, otherwise if multiple columns with same styles they
            // all will be affected multiple times
            final Set<ColumnStyle> colStyles = new HashSet<ColumnStyle>();
            for (final Column<?> col : this.cols) {
                colStyles.add(col.getStyle());
            }
            for (final ColumnStyle colStyle : colStyles) {
                colStyle.setWidth(colStyle.getWidth() * ratio);
            }
        } else {
            // compute table width from column-width
            if (this.getStyle() != null)
                this.getStyle().setWidth(newWidth);
            for (final Column<?> col : this.cols) {
                col.getStyle().rmRelWidth();
            }
        }
    }

    /**
     * Table width.
     * 
     * @return the table width, can be <code>null</code> (table has no style or style has no width,
     *         eg in SpreadSheet).
     */
    public final Float getWidth() {
        final TableStyle style = this.getStyle();
        return style == null ? null : style.getWidth();
    }

    private final ColumnStyle createDefaultColStyle() {
        final ColumnStyle colStyle = ColumnStyle.DESC.createAutoStyle(this.getODDocument().getPackage(), "defaultCol");
        colStyle.setWidth(20.0f);
        return colStyle;
    }

    private final void setCount(final boolean col, final int newSize) {
        this.remove(col, newSize, -1);
    }

    // both inclusive
    private final void remove(final boolean col, final int fromIndex, final int toIndexIncl) {
        // ok since rows and cols are flattened in ctor
        final List<? extends TableCalcNode> l = col ? this.cols : this.rows;
        final int toIndexValid = CollectionUtils.getValidIndex(l, toIndexIncl);
        for (int i = toIndexValid; i >= fromIndex; i--) {
            // works anywhere its XML element is
            l.remove(i).getElement().detach();
        }
    }

    public final void ensureRowCount(int newSize) {
        if (newSize > this.getRowCount())
            this.setRowCount(newSize);
    }

    public final void setRowCount(int newSize) {
        this.setRowCount(newSize, -1);
    }

    /**
     * Changes the row count. If <code>newSize</code> is less than {@link #getRowCount()} extra rows
     * will be chopped off. Otherwise empty cells will be created.
     * 
     * @param newSize the new row count.
     * @param rowIndex the index of the row to be copied, -1 for empty row (i.e. default style).
     */
    public final void setRowCount(int newSize, int rowIndex) {
        final Element elemToClone;
        if (rowIndex < 0) {
            elemToClone = Row.createEmpty(this.getODDocument().getVersion());
            // each row MUST have the same number of columns
            elemToClone.addContent(Cell.createEmpty(this.getODDocument().getVersion(), this.getColumnCount()));
        } else
            elemToClone = getRow(rowIndex).getElement();
        final int toGrow = newSize - this.getRowCount();
        if (toGrow < 0) {
            setCount(false, newSize);
        } else {
            for (int i = 0; i < toGrow; i++) {
                final Element newElem = (Element) elemToClone.clone();
                // as per section 8.1.1 rows are the last elements inside a table
                this.getElement().addContent(newElem);
                addRow(newElem);
            }
        }
    }

    // *** table models

    public final SheetTableModel<D> getTableModel(final int column, final int row) {
        return new SheetTableModel<D>(this, row, column);
    }

    public final SheetTableModel<D> getTableModel(final int column, final int row, final int lastCol, final int lastRow) {
        return new SheetTableModel<D>(this, row, column, lastRow, lastCol);
    }

    public final MutableTableModel<D> getMutableTableModel(final int column, final int row) {
        return new MutableTableModel<D>(this, row, column);
    }

    /**
     * Return the table from <code>start</code> to <code>end</code> inclusive.
     * 
     * @param start the first cell of the result.
     * @param end the last cell of the result.
     * @return the table.
     */
    public final MutableTableModel<D> getMutableTableModel(final Point start, final Point end) {
        // +1 since exclusive
        return new MutableTableModel<D>(this, start.y, start.x, end.y + 1, end.x + 1);
    }

    public final void merge(TableModel t, final int column, final int row) {
        this.merge(t, column, row, false);
    }

    /**
     * Merges t into this sheet at the specified point.
     * 
     * @param t the data to be merged.
     * @param column the columnn t will be merged at.
     * @param row the row t will be merged at.
     * @param includeColNames if <code>true</code> the column names of t will also be merged.
     */
    public final void merge(TableModel t, final int column, final int row, final boolean includeColNames) {
        final int offset = (includeColNames ? 1 : 0);
        // the columns must be first, see section 8.1.1 of v1.1
        this.ensureColumnCount(column + t.getColumnCount());
        this.ensureRowCount(row + t.getRowCount() + offset);
        final TableModel thisModel = this.getMutableTableModel(column, row);
        if (includeColNames) {
            for (int x = 0; x < t.getColumnCount(); x++) {
                thisModel.setValueAt(t.getColumnName(x), 0, x);
            }
        }
        for (int y = 0; y < t.getRowCount(); y++) {
            for (int x = 0; x < t.getColumnCount(); x++) {
                final Object value = t.getValueAt(y, x);
                thisModel.setValueAt(value, y + offset, x);
            }
        }
    }

    // *** static

    /**
     * Convert string coordinates into numeric ones.
     * 
     * @param ref the string address, eg "$AA$34" or "AA34".
     * @return the numeric coordinates or <code>null</code> if <code>ref</code> is not valid, eg
     *         {26, 33}.
     */
    static final Point resolve(String ref) {
        final Matcher matcher = SpreadSheet.minCellPattern.matcher(ref);
        if (!matcher.matches())
            return null;
        return resolve(matcher.group(1), matcher.group(2));
    }

    /**
     * Convert string coordinates into numeric ones. ATTN this method does no checks.
     * 
     * @param letters the column, eg "AA".
     * @param digits the row, eg "34".
     * @return the numeric coordinates, eg {26, 33}.
     */
    static final Point resolve(final String letters, final String digits) {
        return new Point(toInt(letters), Integer.parseInt(digits) - 1);
    }

    // "AA" => 26
    static final int toInt(String col) {
        if (col.length() < 1)
            throw new IllegalArgumentException("x cannot be empty");
        col = col.toUpperCase();

        int x = 0;
        for (int i = 0; i < col.length(); i++) {
            x = x * 26 + (col.charAt(i) - 'A' + 1);
        }

        // zero based
        return x - 1;
    }

}