package autotest.common.spreadsheet;

import autotest.common.UnmodifiableSublistView;
import autotest.common.Utils;
import autotest.common.table.FragmentedTable;
import autotest.common.table.TableRenderer;
import autotest.common.ui.RightClickTable;

import com.google.gwt.dom.client.Element;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.ContextMenuEvent;
import com.google.gwt.event.dom.client.ContextMenuHandler;
import com.google.gwt.event.dom.client.DomEvent;
import com.google.gwt.event.dom.client.ScrollEvent;
import com.google.gwt.event.dom.client.ScrollHandler;
import com.google.gwt.user.client.DeferredCommand;
import com.google.gwt.user.client.IncrementalCommand;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FlexTable;
import com.google.gwt.user.client.ui.HTMLTable;
import com.google.gwt.user.client.ui.Panel;
import com.google.gwt.user.client.ui.ScrollPanel;
import com.google.gwt.user.client.ui.SimplePanel;
import com.google.gwt.user.client.ui.Widget;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Spreadsheet extends Composite
      implements ScrollHandler, ClickHandler, ContextMenuHandler {

    private static final int MIN_TABLE_SIZE_PX = 90;
    private static final int WINDOW_BORDER_PX = 15;
    private static final int SCROLLBAR_FUDGE = 16;
    private static final String BLANK_STRING = "(empty)";
    private static final int CELL_PADDING_PX = 2;
    private static final int TD_BORDER_PX = 1;
    private static final String HIGHLIGHTED_CLASS = "highlighted";
    private static final int CELLS_PER_ITERATION = 1000;

    private Header rowFields, columnFields;
    private List<Header> rowHeaderValues = new ArrayList<Header>();
    private List<Header> columnHeaderValues = new ArrayList<Header>();
    private Map<Header, Integer> rowHeaderMap = new HashMap<Header, Integer>();
    private Map<Header, Integer> columnHeaderMap = new HashMap<Header, Integer>();
    protected CellInfo[][] dataCells, rowHeaderCells, columnHeaderCells;
    private RightClickTable rowHeaders = new RightClickTable();
    private RightClickTable columnHeaders = new RightClickTable();
    private FlexTable parentTable = new FlexTable();
    private FragmentedTable dataTable = new FragmentedTable();
    private int rowsPerIteration;
    private Panel rowHeadersClipPanel, columnHeadersClipPanel;
    private ScrollPanel scrollPanel = new ScrollPanel(dataTable);
    private TableRenderer renderer = new TableRenderer();

    private SpreadsheetListener listener;

    public interface SpreadsheetListener {
        public void onCellClicked(CellInfo cellInfo, boolean isRightClick);
    }

    public static interface Header extends List<String> {}
    public static class HeaderImpl extends ArrayList<String> implements Header {
        public HeaderImpl() {
        }

        public HeaderImpl(Collection<? extends String> arg0) {
            super(arg0);
        }

        public static Header fromBaseType(List<String> baseType) {
            return new HeaderImpl(baseType);
        }
    }

    public static class CellInfo {
        public Header row, column;
        public String contents;
        public String cssClass;
        public Integer widthPx, heightPx;
        public int rowSpan = 1, colSpan = 1;
        public int testCount = 0;
        public int testIndex;

        public CellInfo(Header row, Header column, String contents) {
            this.row = row;
            this.column = column;
            this.contents = contents;
        }

        public boolean isHeader() {
            return !isEmpty() && (row == null || column == null);
        }

        public boolean isEmpty() {
            return row == null && column == null;
        }
    }

    private class RenderCommand implements IncrementalCommand {
        private int state = 0;
        private int rowIndex = 0;
        private IncrementalCommand onFinished;

        public RenderCommand(IncrementalCommand onFinished) {
            this.onFinished = onFinished;
        }

        private void renderSomeRows() {
            renderer.renderRowsAndAppend(dataTable, dataCells,
                                         rowIndex, rowsPerIteration, true);
            rowIndex += rowsPerIteration;
            if (rowIndex > dataCells.length) {
                state++;
            }
        }

        public boolean execute() {
            switch (state) {
                case 0:
                    computeRowsPerIteration();
                    computeHeaderCells();
                    break;
                case 1:
                    renderHeaders();
                    expandRowHeaders();
                    break;
                case 2:
                    // resize everything to the max dimensions (the window size)
                    fillWindow(false);
                    break;
                case 3:
                    // set main table to match header sizes
                    matchRowHeights(rowHeaders, dataCells);
                    matchColumnWidths(columnHeaders, dataCells);
                    dataTable.setVisible(false);
                    break;
                case 4:
                    // render the main data table
                    renderSomeRows();
                    return true;
                case 5:
                    dataTable.updateBodyElems();
                    dataTable.setVisible(true);
                    break;
                case 6:
                    // now expand headers as necessary
                    // this can be very slow, so put it in it's own cycle
                    matchRowHeights(dataTable, rowHeaderCells);
                    break;
                case 7:
                    matchColumnWidths(dataTable, columnHeaderCells);
                    renderHeaders();
                    break;
                case 8:
                    // shrink the scroller if the table ended up smaller than the window
                    fillWindow(true);
                    DeferredCommand.addCommand(onFinished);
                    return false;
            }

            state++;
            return true;
        }
    }

    public Spreadsheet() {
        dataTable.setStyleName("spreadsheet-data");
        killPaddingAndSpacing(dataTable);

        rowHeaders.setStyleName("spreadsheet-headers");
        killPaddingAndSpacing(rowHeaders);
        rowHeadersClipPanel = wrapWithClipper(rowHeaders);

        columnHeaders.setStyleName("spreadsheet-headers");
        killPaddingAndSpacing(columnHeaders);
        columnHeadersClipPanel = wrapWithClipper(columnHeaders);

        scrollPanel.setStyleName("spreadsheet-scroller");
        scrollPanel.setAlwaysShowScrollBars(true);
        scrollPanel.addScrollHandler(this);

        parentTable.setStyleName("spreadsheet-parent");
        killPaddingAndSpacing(parentTable);
        parentTable.setWidget(0, 1, columnHeadersClipPanel);
        parentTable.setWidget(1, 0, rowHeadersClipPanel);
        parentTable.setWidget(1, 1, scrollPanel);

        setupTableInput(dataTable);
        setupTableInput(rowHeaders);
        setupTableInput(columnHeaders);

        initWidget(parentTable);
    }

    private void setupTableInput(RightClickTable table) {
        table.addContextMenuHandler(this);
        table.addClickHandler(this);
    }

    protected void killPaddingAndSpacing(HTMLTable table) {
        table.setCellSpacing(0);
        table.setCellPadding(0);
    }

    /*
     * Wrap a widget with a panel that will clip its contents rather than grow
     * too much.
     */
    protected Panel wrapWithClipper(Widget w) {
        SimplePanel wrapper = new SimplePanel();
        wrapper.add(w);
        wrapper.setStyleName("clipper");
        return wrapper;
    }

    public void setHeaderFields(Header rowFields, Header columnFields) {
        this.rowFields = rowFields;
        this.columnFields = columnFields;
    }

    private void addHeader(List<Header> headerList, Map<Header, Integer> headerMap,
                          List<String> header) {
        Header headerObject = HeaderImpl.fromBaseType(header);
        assert !headerMap.containsKey(headerObject);
        headerList.add(headerObject);
        headerMap.put(headerObject, headerMap.size());
    }

    public void addRowHeader(List<String> header) {
        addHeader(rowHeaderValues, rowHeaderMap, header);
    }

    public void addColumnHeader(List<String> header) {
        addHeader(columnHeaderValues, columnHeaderMap, header);
    }

    private int getHeaderPosition(Map<Header, Integer> headerMap, Header header) {
        assert headerMap.containsKey(header);
        return headerMap.get(header);
    }

    private int getRowPosition(Header rowHeader) {
        return getHeaderPosition(rowHeaderMap, rowHeader);
    }

    private int getColumnPosition(Header columnHeader) {
        return getHeaderPosition(columnHeaderMap, columnHeader);
    }

    /**
     * Must be called after adding headers but before adding data
     */
    public void prepareForData() {
        dataCells = new CellInfo[rowHeaderValues.size()][columnHeaderValues.size()];
    }

    public CellInfo getCellInfo(int row, int column) {
        Header rowHeader = rowHeaderValues.get(row);
        Header columnHeader = columnHeaderValues.get(column);
        if (dataCells[row][column] == null) {
            dataCells[row][column] = new CellInfo(rowHeader, columnHeader, "");
        }
        return dataCells[row][column];
    }

    private CellInfo getCellInfo(CellInfo[][] cells, int row, int column) {
        if (cells[row][column] == null) {
            cells[row][column] = new CellInfo(null, null, " ");
        }
        return cells[row][column];
    }

    /**
     * Render the data into HTML tables.  Done through a deferred command.
     */
    public void render(IncrementalCommand onFinished) {
        DeferredCommand.addCommand(new RenderCommand(onFinished));
    }

    private void renderHeaders() {
        renderer.renderRows(rowHeaders, rowHeaderCells, false);
        renderer.renderRows(columnHeaders, columnHeaderCells, false);
    }

    public void computeRowsPerIteration() {
        int cellsPerRow = columnHeaderValues.size();
        rowsPerIteration = Math.max(CELLS_PER_ITERATION / cellsPerRow, 1);
        dataTable.setRowsPerFragment(rowsPerIteration);
    }

    private void computeHeaderCells() {
        rowHeaderCells = new CellInfo[rowHeaderValues.size()][rowFields.size()];
        fillHeaderCells(rowHeaderCells, rowFields, rowHeaderValues, true);

        columnHeaderCells = new CellInfo[columnFields.size()][columnHeaderValues.size()];
        fillHeaderCells(columnHeaderCells, columnFields, columnHeaderValues, false);
    }

    /**
     * TODO (post-1.0) - this method needs good cleanup and documentation
     */
    private void fillHeaderCells(CellInfo[][] cells, Header fields, List<Header> headerValues,
                                 boolean isRows) {
        int headerSize = fields.size();
        String[] lastFieldValue = new String[headerSize];
        CellInfo[] lastCellInfo = new CellInfo[headerSize];
        int[] counter = new int[headerSize];
        boolean newHeader;
        for (int headerIndex = 0; headerIndex < headerValues.size(); headerIndex++) {
            Header header = headerValues.get(headerIndex);
            newHeader = false;
            for (int fieldIndex = 0; fieldIndex < headerSize; fieldIndex++) {
                String fieldValue = header.get(fieldIndex);
                if (newHeader || !fieldValue.equals(lastFieldValue[fieldIndex])) {
                    newHeader = true;
                    Header currentHeader = getSubHeader(header, fieldIndex + 1);
                    String cellContents = formatHeader(fields.get(fieldIndex), fieldValue);
                    CellInfo cellInfo;
                    if (isRows) {
                        cellInfo = new CellInfo(currentHeader, null, cellContents);
                        cells[headerIndex][fieldIndex] = cellInfo;
                    } else {
                        cellInfo = new CellInfo(null, currentHeader, cellContents);
                        cells[fieldIndex][counter[fieldIndex]] = cellInfo;
                        counter[fieldIndex]++;
                    }
                    lastFieldValue[fieldIndex] = fieldValue;
                    lastCellInfo[fieldIndex] = cellInfo;
                } else {
                    incrementSpan(lastCellInfo[fieldIndex], isRows);
                }
            }
        }
    }

    private String formatHeader(String field, String value) {
        if (value.equals("")) {
            return BLANK_STRING;
        }
        value = Utils.escape(value);
        if (field.equals("kernel")) {
            // line break after each /, for long paths
            value = value.replace("/", "/<br>").replace("/<br>/<br>", "//");
        }
        return value;
    }

    private void incrementSpan(CellInfo cellInfo, boolean isRows) {
        if (isRows) {
            cellInfo.rowSpan++;
        } else {
            cellInfo.colSpan++;
        }
    }

    private Header getSubHeader(Header header, int length) {
        if (length == header.size()) {
            return header;
        }
        List<String> subHeader = new UnmodifiableSublistView<String>(header, 0, length);
        return new HeaderImpl(subHeader);
    }

    private void matchRowHeights(HTMLTable from, CellInfo[][] to) {
        int lastColumn = to[0].length - 1;
        int rowCount = from.getRowCount();
        for (int row = 0; row < rowCount; row++) {
            int height = getRowHeight(from, row);
            getCellInfo(to, row, lastColumn).heightPx = height - 2 * CELL_PADDING_PX;
        }
    }

    private void matchColumnWidths(HTMLTable from, CellInfo[][] to) {
        int lastToRow = to.length - 1;
        int lastFromRow = from.getRowCount() - 1;
        for (int column = 0; column < from.getCellCount(lastFromRow); column++) {
            int width = getColumnWidth(from, column);
            getCellInfo(to, lastToRow, column).widthPx = width - 2 * CELL_PADDING_PX;
        }
    }

    protected String getTableCellText(HTMLTable table, int row, int column) {
        Element td = table.getCellFormatter().getElement(row, column);
        Element div = td.getFirstChildElement();
        if (div == null)
            return null;
        String contents = Utils.unescape(div.getInnerHTML());
        if (contents.equals(BLANK_STRING))
            contents = "";
        return contents;
    }

    public void clear() {
        rowHeaderValues.clear();
        columnHeaderValues.clear();
        rowHeaderMap.clear();
        columnHeaderMap.clear();
        dataCells = rowHeaderCells = columnHeaderCells = null;
        dataTable.reset();

        setRowHeadersOffset(0);
        setColumnHeadersOffset(0);
    }

    /**
     * Make the spreadsheet fill the available window space to the right and bottom
     * of its position.
     */
    public void fillWindow(boolean useTableSize) {
        int newHeightPx = Window.getClientHeight() - (columnHeaders.getAbsoluteTop() +
                                                      columnHeaders.getOffsetHeight());
        newHeightPx = adjustMaxDimension(newHeightPx);
        int newWidthPx = Window.getClientWidth() - (rowHeaders.getAbsoluteLeft() +
                                                    rowHeaders.getOffsetWidth());
        newWidthPx = adjustMaxDimension(newWidthPx);
        if (useTableSize) {
            newHeightPx = Math.min(newHeightPx, rowHeaders.getOffsetHeight());
            newWidthPx = Math.min(newWidthPx, columnHeaders.getOffsetWidth());
        }

        // apply the changes all together
        rowHeadersClipPanel.setHeight(getSizePxString(newHeightPx));
        columnHeadersClipPanel.setWidth(getSizePxString(newWidthPx));
        scrollPanel.setSize(getSizePxString(newWidthPx + SCROLLBAR_FUDGE),
                            getSizePxString(newHeightPx + SCROLLBAR_FUDGE));
    }

    /**
     * Adjust a maximum table dimension to allow room for edge decoration and
     * always maintain a minimum height
     */
    protected int adjustMaxDimension(int maxDimensionPx) {
        return Math.max(maxDimensionPx - WINDOW_BORDER_PX - SCROLLBAR_FUDGE,
                        MIN_TABLE_SIZE_PX);
    }

    protected String getSizePxString(int sizePx) {
        return sizePx + "px";
    }

    /**
     * Ensure the row header clip panel allows the full width of the row headers
     * to display.
     */
    protected void expandRowHeaders() {
        int width = rowHeaders.getOffsetWidth();
        rowHeadersClipPanel.setWidth(getSizePxString(width));
    }

    private Element getCellElement(HTMLTable table, int row, int column) {
        return table.getCellFormatter().getElement(row, column);
    }

    private Element getCellElement(CellInfo cellInfo) {
        assert cellInfo.row != null || cellInfo.column != null;
        Element tdElement;
        if (cellInfo.row == null) {
            tdElement = getCellElement(columnHeaders, 0, getColumnPosition(cellInfo.column));
        } else if (cellInfo.column == null) {
            tdElement = getCellElement(rowHeaders, getRowPosition(cellInfo.row), 0);
        } else {
            tdElement = getCellElement(dataTable, getRowPosition(cellInfo.row),
                                                  getColumnPosition(cellInfo.column));
        }
        Element cellElement = tdElement.getFirstChildElement();
        assert cellElement != null;
        return cellElement;
    }

    protected int getColumnWidth(HTMLTable table, int column) {
        // using the column formatter doesn't seem to work
        int numRows = table.getRowCount();
        return table.getCellFormatter().getElement(numRows - 1, column).getOffsetWidth() -
               TD_BORDER_PX;
    }

    protected int getRowHeight(HTMLTable table, int row) {
        // see getColumnWidth()
        int numCols = table.getCellCount(row);
        return table.getCellFormatter().getElement(row, numCols - 1).getOffsetHeight() -
               TD_BORDER_PX;
    }

    /**
     * Update floating headers.
     */
    @Override
    public void onScroll(ScrollEvent event) {
        int scrollLeft = scrollPanel.getHorizontalScrollPosition();
        int scrollTop = scrollPanel.getScrollPosition();

        setColumnHeadersOffset(-scrollLeft);
        setRowHeadersOffset(-scrollTop);
    }

    protected void setRowHeadersOffset(int offset) {
        rowHeaders.getElement().getStyle().setPropertyPx("top", offset);
    }

    protected void setColumnHeadersOffset(int offset) {
        columnHeaders.getElement().getStyle().setPropertyPx("left", offset);
    }

    @Override
    public void onClick(ClickEvent event) {
        handleEvent(event, false);
    }

    @Override
    public void onContextMenu(ContextMenuEvent event) {
        handleEvent(event, true);
    }

    private void handleEvent(DomEvent<?> event, boolean isRightClick) {
        if (listener == null)
            return;

        assert event.getSource() instanceof RightClickTable;
        HTMLTable.Cell tableCell = ((RightClickTable) event.getSource()).getCellForDomEvent(event);
        int row = tableCell.getRowIndex();
        int column = tableCell.getCellIndex();

        CellInfo[][] cells;
        if (event.getSource() == rowHeaders) {
            cells = rowHeaderCells;
            column = adjustRowHeaderColumnIndex(row, column);
        }
        else if (event.getSource() == columnHeaders) {
            cells = columnHeaderCells;
        }
        else {
            assert event.getSource() == dataTable;
            cells = dataCells;
        }
        CellInfo cell = cells[row][column];
        if (cell == null || cell.isEmpty())
            return; // don't report clicks on empty cells

        listener.onCellClicked(cell, isRightClick);
    }

    /**
     * In HTMLTables, a cell with rowspan > 1 won't count in column indices for the extra rows it
     * spans, which will mess up column indices for other cells in those rows.  This method adjusts
     * the column index passed to onCellClicked() to account for that.
     */
    private int adjustRowHeaderColumnIndex(int row, int column) {
        for (int i = 0; i < rowFields.size(); i++) {
            if (rowHeaderCells[row][i] != null) {
                return i + column;
            }
        }

        throw new RuntimeException("Failed to find non-null cell");
    }

    public void setListener(SpreadsheetListener listener) {
        this.listener = listener;
    }

    public void setHighlighted(CellInfo cell, boolean highlighted) {
        Element cellElement = getCellElement(cell);
        if (highlighted) {
            cellElement.setClassName(HIGHLIGHTED_CLASS);
        } else {
            cellElement.setClassName("");
        }
    }

    public List<Integer> getAllTestIndices() {
        List<Integer> testIndices = new ArrayList<Integer>();

        for (CellInfo[] row : dataCells) {
            for (CellInfo cellInfo : row) {
                if (cellInfo != null && !cellInfo.isEmpty()) {
                    testIndices.add(cellInfo.testIndex);
                }
            }
        }

        return testIndices;
    }
}
