/* ====================================================================
   Licensed to the Apache Software Foundation (ASF) under one or more
   contributor license agreements.  See the NOTICE file distributed with
   this work for additional information regarding copyright ownership.
   The ASF licenses this file to You 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.
==================================================================== */

package org.apache.poi.hssf.record.aggregates;

import java.util.ArrayList;
import java.util.List;

import org.apache.poi.common.Duplicatable;
import org.apache.poi.hssf.model.RecordStream;
import org.apache.poi.hssf.record.ColumnInfoRecord;

public final class ColumnInfoRecordsAggregate extends RecordAggregate implements Duplicatable {
    /**
     * List of {@link ColumnInfoRecord}s assumed to be in order
     */
    private final List<ColumnInfoRecord> records = new ArrayList<>();

    /**
     * Creates an empty aggregate
     */
    public ColumnInfoRecordsAggregate() {}

    public ColumnInfoRecordsAggregate(ColumnInfoRecordsAggregate other) {
        other.records.stream().map(ColumnInfoRecord::copy).forEach(records::add);
    }

    public ColumnInfoRecordsAggregate(RecordStream rs) {
        this();

        boolean isInOrder = true;
        ColumnInfoRecord cirPrev = null;
        while (rs.peekNextClass() == ColumnInfoRecord.class) {
            ColumnInfoRecord cir = (ColumnInfoRecord) rs.getNext();
            records.add(cir);
            if (cirPrev != null && compareColInfos(cirPrev, cir) > 0) {
                isInOrder = false;
            }
            cirPrev = cir;
        }
        if (records.size() < 1) {
            throw new RuntimeException("No column info records found");
        }
        if (!isInOrder) {
            records.sort(ColumnInfoRecordsAggregate::compareColInfos);
        }
    }

    @Override
    public ColumnInfoRecordsAggregate copy() {
        return new ColumnInfoRecordsAggregate(this);
    }

    /**
     * Inserts a column into the aggregate (at the end of the list).
     */
    public void insertColumn(ColumnInfoRecord col) {
        records.add(col);
        records.sort(ColumnInfoRecordsAggregate::compareColInfos);
    }

    /**
     * Inserts a column into the aggregate (at the position specified by
     * {@code idx}.
     */
    private void insertColumn(int idx, ColumnInfoRecord col) {
        records.add(idx, col);
    }

    /* package */ int getNumColumns() {
        return records.size();
    }

    @Override
    public void visitContainedRecords(RecordVisitor rv) {
        int nItems = records.size();
        if (nItems < 1) {
            return;
        }
        ColumnInfoRecord cirPrev = null;
        for (ColumnInfoRecord cir : records) {
            rv.visitRecord(cir);
            if (cirPrev != null && compareColInfos(cirPrev, cir) > 0) {
                // Excel probably wouldn't mind, but there is much logic in this class
                // that assumes the column info records are kept in order
                throw new RuntimeException("Column info records are out of order");
            }
            cirPrev = cir;
        }
    }

    private int findStartOfColumnOutlineGroup(int pIdx) {
        // Find the start of the group.
        ColumnInfoRecord columnInfo = records.get(pIdx);
        int level = columnInfo.getOutlineLevel();
        int idx = pIdx;
        while (idx != 0) {
            ColumnInfoRecord prevColumnInfo = records.get(idx - 1);
            if (!prevColumnInfo.isAdjacentBefore(columnInfo)) {
                break;
            }
            if (prevColumnInfo.getOutlineLevel() < level) {
                break;
            }
            idx--;
            columnInfo = prevColumnInfo;
        }

        return idx;
    }

    private int findEndOfColumnOutlineGroup(int colInfoIndex) {
        // Find the end of the group.
        ColumnInfoRecord columnInfo = records.get(colInfoIndex);
        int level = columnInfo.getOutlineLevel();
        int idx = colInfoIndex;
        while (idx < records.size() - 1) {
            ColumnInfoRecord nextColumnInfo = records.get(idx + 1);
            if (!columnInfo.isAdjacentBefore(nextColumnInfo)) {
                break;
            }
            if (nextColumnInfo.getOutlineLevel() < level) {
                break;
            }
            idx++;
            columnInfo = nextColumnInfo;
        }
        return idx;
    }

    private ColumnInfoRecord getColInfo(int idx) {
        return records.get( idx );
    }

    /**
     * 'Collapsed' state is stored in a single column col info record immediately after the outline group
     * @return true, if the column is collapsed, false otherwise.
     */
    private boolean isColumnGroupCollapsed(int idx) {
        int endOfOutlineGroupIdx = findEndOfColumnOutlineGroup(idx);
        int nextColInfoIx = endOfOutlineGroupIdx+1;
        if (nextColInfoIx >= records.size()) {
            return false;
        }
        ColumnInfoRecord nextColInfo = getColInfo(nextColInfoIx);
        if (!getColInfo(endOfOutlineGroupIdx).isAdjacentBefore(nextColInfo)) {
            return false;
        }
        return nextColInfo.getCollapsed();
    }


    private boolean isColumnGroupHiddenByParent(int idx) {
        // Look out outline details of end
        int endLevel = 0;
        boolean endHidden = false;
        int endOfOutlineGroupIdx = findEndOfColumnOutlineGroup( idx );
        if (endOfOutlineGroupIdx < records.size()) {
            ColumnInfoRecord nextInfo = getColInfo(endOfOutlineGroupIdx + 1);
            if (getColInfo(endOfOutlineGroupIdx).isAdjacentBefore(nextInfo)) {
                endLevel = nextInfo.getOutlineLevel();
                endHidden = nextInfo.getHidden();
            }
        }
        // Look out outline details of start
        int startLevel = 0;
        boolean startHidden = false;
        int startOfOutlineGroupIdx = findStartOfColumnOutlineGroup( idx );
        if (startOfOutlineGroupIdx > 0) {
            ColumnInfoRecord prevInfo = getColInfo(startOfOutlineGroupIdx - 1);
            if (prevInfo.isAdjacentBefore(getColInfo(startOfOutlineGroupIdx))) {
                startLevel = prevInfo.getOutlineLevel();
                startHidden = prevInfo.getHidden();
            }
        }
        if (endLevel > startLevel) {
            return endHidden;
        }
        return startHidden;
    }

    public void collapseColumn(int columnIndex) {
        int colInfoIx = findColInfoIdx(columnIndex, 0);
        if (colInfoIx == -1) {
            return;
        }

        // Find the start of the group.
        int groupStartColInfoIx = findStartOfColumnOutlineGroup(colInfoIx);
        ColumnInfoRecord columnInfo = getColInfo(groupStartColInfoIx);

        // Hide all the columns until the end of the group
        int lastColIx = setGroupHidden(groupStartColInfoIx, columnInfo.getOutlineLevel(), true);

        // Write collapse field
        setColumn(lastColIx + 1, null, null, null, null, Boolean.TRUE);
    }
    /**
     * Sets all adjacent columns of the same outline level to the specified hidden status.
     * @param pIdx the col info index of the start of the outline group
     * @return the column index of the last column in the outline group
     */
    private int setGroupHidden(int pIdx, int level, boolean hidden) {
        int idx = pIdx;
        ColumnInfoRecord columnInfo = getColInfo(idx);
        while (idx < records.size()) {
            columnInfo.setHidden(hidden);
            if (idx + 1 < records.size()) {
                ColumnInfoRecord nextColumnInfo = getColInfo(idx + 1);
                if (!columnInfo.isAdjacentBefore(nextColumnInfo)) {
                    break;
                }
                if (nextColumnInfo.getOutlineLevel() < level) {
                    break;
                }
                columnInfo = nextColumnInfo;
            }
            idx++;
        }
        return columnInfo.getLastColumn();
    }


    public void expandColumn(int columnIndex) {
        int idx = findColInfoIdx(columnIndex, 0);
        if (idx == -1) {
            return;
        }

        // If it is already expanded do nothing.
        if (!isColumnGroupCollapsed(idx)) {
            return;
        }

        // Find the start/end of the group.
        int startIdx = findStartOfColumnOutlineGroup(idx);
        int endIdx = findEndOfColumnOutlineGroup(idx);

        // expand:
        // collapsed bit must be unset
        // hidden bit gets unset _if_ surrounding groups are expanded you can determine
        //   this by looking at the hidden bit of the enclosing group.  You will have
        //   to look at the start and the end of the current group to determine which
        //   is the enclosing group
        // hidden bit only is altered for this outline level.  ie.  don't uncollapse contained groups
        ColumnInfoRecord columnInfo = getColInfo(endIdx);
        if (!isColumnGroupHiddenByParent(idx)) {
            int outlineLevel = columnInfo.getOutlineLevel();
            for (int i = startIdx; i <= endIdx; i++) {
                ColumnInfoRecord ci = getColInfo(i);
                if (outlineLevel == ci.getOutlineLevel())
                    ci.setHidden(false);
            }
        }

        // Write collapse flag (stored in a single col info record after this outline group)
        setColumn(columnInfo.getLastColumn() + 1, null, null, null, null, Boolean.FALSE);
    }

    private static ColumnInfoRecord copyColInfo(ColumnInfoRecord ci) {
        return ci.copy();
    }


    public void setColumn(int targetColumnIx, Short xfIndex, Integer width,
                    Integer level, Boolean hidden, Boolean collapsed) {
        ColumnInfoRecord ci = null;
        int k;
        for (k = 0; k < records.size(); k++) {
            ColumnInfoRecord tci = records.get(k);
            if (tci.containsColumn(targetColumnIx)) {
                ci = tci;
                break;
            }
            if (tci.getFirstColumn() > targetColumnIx) {
                // call column infos after k are for later columns
                break; // exit now so k will be the correct insert pos
            }
        }

        if (ci == null) {
            // okay so there ISN'T a column info record that covers this column so lets create one!
            ColumnInfoRecord nci = new ColumnInfoRecord();

            nci.setFirstColumn(targetColumnIx);
            nci.setLastColumn(targetColumnIx);
            setColumnInfoFields( nci, xfIndex, width, level, hidden, collapsed );
            insertColumn(k, nci);
            attemptMergeColInfoRecords(k);
            return;
        }

        boolean styleChanged = xfIndex != null && ci.getXFIndex() != xfIndex.shortValue();
        boolean widthChanged = width != null && ci.getColumnWidth() != width.shortValue();
        boolean levelChanged = level != null && ci.getOutlineLevel() != level.intValue();
        boolean hiddenChanged = hidden != null && ci.getHidden() != hidden.booleanValue();
        boolean collapsedChanged = collapsed != null && ci.getCollapsed() != collapsed.booleanValue();

        boolean columnChanged = styleChanged || widthChanged || levelChanged || hiddenChanged || collapsedChanged;
        if (!columnChanged) {
            // do nothing...nothing changed.
            return;
        }

        if (ci.getFirstColumn() == targetColumnIx && ci.getLastColumn() == targetColumnIx) {
            // ColumnInfo ci for a single column, the target column
            setColumnInfoFields(ci, xfIndex, width, level, hidden, collapsed);
            attemptMergeColInfoRecords(k);
            return;
        }

        if (ci.getFirstColumn() == targetColumnIx || ci.getLastColumn() == targetColumnIx) {
            // The target column is at either end of the multi-column ColumnInfo ci
            // we'll just divide the info and create a new one
            if (ci.getFirstColumn() == targetColumnIx) {
                ci.setFirstColumn(targetColumnIx + 1);
            } else {
                ci.setLastColumn(targetColumnIx - 1);
                k++; // adjust insert pos to insert after
            }
            ColumnInfoRecord nci = copyColInfo(ci);

            nci.setFirstColumn(targetColumnIx);
            nci.setLastColumn(targetColumnIx);
            setColumnInfoFields( nci, xfIndex, width, level, hidden, collapsed );

            insertColumn(k, nci);
            attemptMergeColInfoRecords(k);
        } else {
            //split to 3 records
            ColumnInfoRecord ciMid = copyColInfo(ci);
            ColumnInfoRecord ciEnd = copyColInfo(ci);
            int lastcolumn = ci.getLastColumn();

            ci.setLastColumn(targetColumnIx - 1);

            ciMid.setFirstColumn(targetColumnIx);
            ciMid.setLastColumn(targetColumnIx);
            setColumnInfoFields(ciMid, xfIndex, width, level, hidden, collapsed);
            insertColumn(++k, ciMid);

            ciEnd.setFirstColumn(targetColumnIx+1);
            ciEnd.setLastColumn(lastcolumn);
            insertColumn(++k, ciEnd);
            // no need to attemptMergeColInfoRecords because we
            // know both on each side are different
        }
    }

    /**
     * Sets all non null fields into the {@code ci} parameter.
     */
    private static void setColumnInfoFields(ColumnInfoRecord ci, Short xfStyle, Integer width,
                Integer level, Boolean hidden, Boolean collapsed) {
        if (xfStyle != null) {
            ci.setXFIndex(xfStyle.shortValue());
        }
        if (width != null) {
            ci.setColumnWidth(width.intValue());
        }
        if (level != null) {
            ci.setOutlineLevel( level.shortValue() );
        }
        if (hidden != null) {
            ci.setHidden( hidden.booleanValue() );
        }
        if (collapsed != null) {
            ci.setCollapsed( collapsed.booleanValue() );
        }
    }

    private int findColInfoIdx(int columnIx, int fromColInfoIdx) {
        if (columnIx < 0) {
            throw new IllegalArgumentException( "column parameter out of range: " + columnIx );
        }
        if (fromColInfoIdx < 0) {
            throw new IllegalArgumentException( "fromIdx parameter out of range: " + fromColInfoIdx );
        }

        for (int k = fromColInfoIdx; k < records.size(); k++) {
            ColumnInfoRecord ci = getColInfo(k);
            if (ci.containsColumn(columnIx)) {
                return k;
            }
            if (ci.getFirstColumn() > columnIx) {
                break;
            }
        }
        return -1;
    }

    /**
     * Attempts to merge the col info record at the specified index
     * with either or both of its neighbours
     */
    private void attemptMergeColInfoRecords(int colInfoIx) {
        int nRecords = records.size();
        if (colInfoIx < 0 || colInfoIx >= nRecords) {
            throw new IllegalArgumentException("colInfoIx " + colInfoIx
                    + " is out of range (0.." + (nRecords-1) + ")");
        }
        ColumnInfoRecord currentCol = getColInfo(colInfoIx);
        int nextIx = colInfoIx+1;
        if (nextIx < nRecords) {
            if (mergeColInfoRecords(currentCol, getColInfo(nextIx))) {
                records.remove(nextIx);
            }
        }
        if (colInfoIx > 0) {
            if (mergeColInfoRecords(getColInfo(colInfoIx - 1), currentCol)) {
                records.remove(colInfoIx);
            }
        }
    }
    /**
     * merges two column info records (if they are adjacent and have the same formatting, etc)
     * @return {@code false} if the two column records could not be merged
     */
    private static boolean mergeColInfoRecords(ColumnInfoRecord ciA, ColumnInfoRecord ciB) {
        if (ciA.isAdjacentBefore(ciB) && ciA.formatMatches(ciB)) {
            ciA.setLastColumn(ciB.getLastColumn());
            return true;
        }
        return false;
    }
    /**
     * Creates an outline group for the specified columns, by setting the level
     * field for each col info record in the range. {@link ColumnInfoRecord}s
     * may be created, split or merged as a result of this operation.
     *
     * @param fromColumnIx
     *            group from this column (inclusive)
     * @param toColumnIx
     *            group to this column (inclusive)
     * @param indent
     *            if {@code true} the group will be indented by one
     *            level, if {@code false} indenting will be decreased by
     *            one level.
     */
    public void groupColumnRange(int fromColumnIx, int toColumnIx, boolean indent) {

        int colInfoSearchStartIdx = 0; // optimization to speed up the search for col infos
        for (int i = fromColumnIx; i <= toColumnIx; i++) {
            int level = 1;
            int colInfoIdx = findColInfoIdx(i, colInfoSearchStartIdx);
            if (colInfoIdx != -1) {
                level = getColInfo(colInfoIdx).getOutlineLevel();
                if (indent) {
                    level++;
                } else {
                    level--;
                }
                level = Math.max(0, level);
                level = Math.min(7, level);
                colInfoSearchStartIdx = Math.max(0, colInfoIdx - 1); // -1 just in case this column is collapsed later.
            }
            setColumn(i, null, null, Integer.valueOf(level), null, null);
        }
    }

    /**
     * Finds the {@code ColumnInfoRecord} which contains the specified columnIndex
     * @param columnIndex index of the column (not the index of the ColumnInfoRecord)
     * @return {@code null} if no column info found for the specified column
     */
    public ColumnInfoRecord findColumnInfo(int columnIndex) {
        int nInfos = records.size();
        for(int i=0; i< nInfos; i++) {
            ColumnInfoRecord ci = getColInfo(i);
            if (ci.containsColumn(columnIndex)) {
                return ci;
            }
        }
        return null;
    }

    public int getMaxOutlineLevel() {
        int result = 0;
        int count=records.size();
        for (int i=0; i<count; i++) {
            ColumnInfoRecord columnInfoRecord = getColInfo(i);
            result = Math.max(columnInfoRecord.getOutlineLevel(), result);
        }
        return result;
    }

    public int getOutlineLevel(int columnIndex) {
        ColumnInfoRecord ci = findColumnInfo(columnIndex);
        if (ci != null) {
            return ci.getOutlineLevel();
        } else {
            return 0;
        }
    }

    public int getMinColumnIndex() {
        if(records.isEmpty()) {
            return 0;
        }

        int minIndex = Integer.MAX_VALUE;
        int nInfos = records.size();
        for(int i=0; i< nInfos; i++) {
            ColumnInfoRecord ci = getColInfo(i);
            minIndex = Math.min(minIndex, ci.getFirstColumn());
        }

        return minIndex;
    }

    public int getMaxColumnIndex() {
        if(records.isEmpty()) {
            return 0;
        }

        int maxIndex = 0;
        int nInfos = records.size();
        for(int i=0; i< nInfos; i++) {
            ColumnInfoRecord ci = getColInfo(i);
            maxIndex = Math.max(maxIndex, ci.getLastColumn());
        }

        return maxIndex;
    }

    private static int compareColInfos(ColumnInfoRecord a, ColumnInfoRecord b) {
        return a.getFirstColumn()-b.getFirstColumn();
    }
}
