/*
 * Copyright 2017 the original author or authors.
 *
 * 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
 *
 *      https://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.springframework.shell.table;

import static org.springframework.shell.table.BorderSpecification.NONE;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.shell.TerminalSizeAware;

/**
 * This is the central API for table rendering. A Table object is constructed with a given
 * TableModel, which holds raw table contents. Its rendering logic is then altered by applying
 * various customizations, in a fashion very similar to what is used <i>e.g.</i> in a spreadsheet
 * program:<ol>
 * <li>{@link #formatters formatters} know how to derive character data out of raw data. For
 * example, numbers are
 * formatted according to a Locale, or Maps are emitted as a series of {@literal key=value} lines</li>
 * <li>{@link #sizeConstraints size constraints} are then applied, which decide how
 * much column real estate to allocate to cells</li>
 * <li>{@link #wrappers text wrapping policies} are applied once the column sizes
 * are known</li>
 * <li>finally, {@link #aligners alignment} strategies actually render
 * text as a series of space-padded strings that draw nicely on screen.</li>
 * </ol>
 * All those customizations are applied selectively on the Table cells thanks to a {@link CellMatcher}: One can
 * decide to right pad column number 3, or to format in a certain way all instances of {@literal java.util.Map}.
 *
 * <p>Of course, all of those customizations often work hand in hand, and not all combinations make sense:
 * one needs to anticipate the fact that text will be split using the ' ' (space) character to properly
 * calculate column sizes.</p>
 * @author Eric Bottard
 */
public class Table implements TerminalSizeAware {

	private final int rows;

	private final int columns;

	private TableModel model;

	private Map<CellMatcher, Formatter> formatters = new LinkedHashMap<CellMatcher, Formatter>();

	private Map<CellMatcher, SizeConstraints> sizeConstraints = new LinkedHashMap<CellMatcher, SizeConstraints>();

	private Map<CellMatcher, TextWrapper> wrappers = new LinkedHashMap<CellMatcher, TextWrapper>();

	private Map<CellMatcher, Aligner> aligners = new LinkedHashMap<CellMatcher, Aligner>();

	private List<BorderSpecification> borderSpecifications = new ArrayList<BorderSpecification>();

	/**
	 * Construct a new Table with the given model and customizers.
	 * The passed in LinkedHashMap should be in reverse-insertion order (<i>i.e.</i> the first CellMatcher
	 * found in iteration order will "win").
	 *
	 * @see TableBuilder#build()
	 */
	/*package*/ Table(TableModel model,
	      LinkedHashMap<CellMatcher, Formatter> formatters,
	      LinkedHashMap<CellMatcher, SizeConstraints> sizeConstraints,
	      LinkedHashMap<CellMatcher, TextWrapper> wrappers,
	      LinkedHashMap<CellMatcher, Aligner> aligners,
	      List<BorderSpecification> borderSpecifications) {
		this.model = model;
		this.formatters = formatters;
		this.sizeConstraints = sizeConstraints;
		this.wrappers = wrappers;
		this.aligners = aligners;
		this.borderSpecifications = borderSpecifications;
		rows = model.getRowCount();
		columns = model.getColumnCount();

	}

	public TableModel getModel() {
		return model;
	}

	public String render(int totalAvailableWidth) {
		StringBuilder result = new StringBuilder();

		int[] cellHeights = new int[rows];
		int[] cellWidths;
		int[] minCellWidths = new int[columns];
		int[] maxCellWidths = new int[columns];

		String[][][] subLines = new String[rows][columns][];

		Borders borders = new Borders();
		int widthAvailableForContents = totalAvailableWidth - borders.getNumberOfVerticalBorders();

		// First, compute desired column widths
		for (int row = 0; row < rows; row++) {
			for (int column = 0; column < columns; column++) {
				Object value = model.getValue(row, column);
				String[] lines = getFormatter(row, column).format(value);
				subLines[row][column] = lines;

				SizeConstraints.Extent extent = getSizeConstraints(row, column).width(lines, widthAvailableForContents, columns);

				minCellWidths[column] = Math.max(minCellWidths[column], extent.min);
				maxCellWidths[column] = Math.max(maxCellWidths[column], extent.max);

			}
		}


		cellWidths = computeColumnWidths(widthAvailableForContents, minCellWidths, maxCellWidths);
		// Now that widths are known, apply wrapping & render
		for (int row = 0; row < rows; row++) {
			for (int column = 0; column < columns; column++) {
				subLines[row][column] = getWrapper(row, column).wrap(subLines[row][column], cellWidths[column]);
				cellHeights[row] = Math.max(cellHeights[row], subLines[row][column].length);
			}
			for (int column = 0; column < columns; column++) {
				for (Map.Entry<CellMatcher, Aligner> kv : aligners.entrySet()) {
					if (kv.getKey().matches(row, column, model)) {
						subLines[row][column] = kv.getValue().align(subLines[row][column], cellWidths[column], cellHeights[row]);
					}
				}
			}
		}


		for (int row = 0; row < rows; row++) {

			// TOP CELL BORDER
			int before = result.length();
			for (int column = 0; column < columns; column++) {
				borders.paintCorner(row, column, result);
				borders.paintHorizontal(row, column, cellWidths[column], result);
			}
			borders.paintCorner(row, columns, result);
			if (result.length() > before) {
				result.append('\n');
			}

			for (int subRow = 0; subRow < cellHeights[row]; subRow++) {
				for (int column = 0; column < columns; column++) {
					// LEFT CELL BORDER
					borders.paintVertical(row, column, result);
					String[] lines = subLines[row][column];
					result.append(lines[subRow]);
				}
				// TABLE RIGHT BORDER
				borders.paintVertical(row, columns, result);
				result.append("\n");
			}
		}

		// TABLE BOTTOM BORDER
		int before = result.length();
		for (int column = 0; column < columns; column++) {
			borders.paintCorner(rows, column, result);
			borders.paintHorizontal(rows, column, cellWidths[column], result);
		}

		// TABLE BOTTOM RIGHT CORNER
		borders.paintCorner(rows, columns, result);
		if (result.length() > before) {
			result.append('\n');
		}
		return result.toString();
	}

	private int[] computeColumnWidths(int availableWidth, int[] minCellWidths, int[] maxCellWidths) {

		int[] cellWidths;
		int minTableWidth = 0, maxTableWidth = 0;
		for (int column = 0; column < columns; column++) {
			minTableWidth += minCellWidths[column];
			maxTableWidth += maxCellWidths[column];
		}

		// Can use max desired width
		if (maxTableWidth <= availableWidth) {
			cellWidths = maxCellWidths;
		} // will overflow
		else if (minTableWidth >= availableWidth) {
			cellWidths = minCellWidths;
		} // Redistribute nicely
		else {
			int W = availableWidth - minTableWidth;
			int D = maxTableWidth - minTableWidth;
			cellWidths = new int[columns];
			for (int column = 0; column < columns; column++) {
				cellWidths[column] = minCellWidths[column] + W * (maxCellWidths[column] - minCellWidths[column]) / D;
			}
		}
		return cellWidths;
	}

	private TextWrapper getWrapper(int row, int column) {
		for (Map.Entry<CellMatcher, TextWrapper> kv : wrappers.entrySet()) {
			if (kv.getKey().matches(row, column, model)) {
				return kv.getValue();
			}
		}
		throw new AssertionError("Can't be reached thanks to the whole-table default");
	}

	private SizeConstraints getSizeConstraints(int row, int column) {
		for (Map.Entry<CellMatcher, SizeConstraints> kv : sizeConstraints.entrySet()) {
			if (kv.getKey().matches(row, column, model)) {
				return kv.getValue();
			}
		}
		throw new AssertionError("Can't be reached thanks to the whole-table default");
	}

	private Formatter getFormatter(int row, int column) {
		for (Map.Entry<CellMatcher, Formatter> kv : formatters.entrySet()) {
			if (kv.getKey().matches(row, column, model)) {
				return  kv.getValue();
			}
		}
		throw new AssertionError("Can't be reached thanks to the whole-table default");
	}

	/**
	 * An instance of this class knows where to paint border glyphs.
	 *
	 * <p>In all instance arrays, 'row' and 'column' are actually indices in-between
	 * table rows and columns. Hence, sizes are larger by one.</p>
	 * @author Eric Bottard
	 */
	private class Borders {

		/**
		 * Glyph to paint a vertical line at row,col.
		 */
		private char[][] verticals;

		/**
		 * Glyph to paint a horizontal line at row,col.
		 */
		private char[][] horizontals;

		/**
		 * The type of corner, if any, to paint at row,col.
		 */
		private char[][] corners;

		/**
		 * True if at least one vertical bar exists in that col.
		 */
		private boolean[] vFillers;

		/**
		 * True if at least one horizontal bar exists in that row.
		 */
		private boolean[] hFillers;

		public Borders() {
			verticals = new char[rows][columns + 1];
			horizontals = new char[rows + 1][columns];
			corners = new char[rows + 1][columns + 1];
			vFillers = new boolean[columns + 1];
			hFillers = new boolean[rows + 1];
			init();
		}

		private void init() {

			for (int row = 0; row <= rows; row++) {
				for (int column = 0; column <= columns; column++) {
					for (BorderSpecification bs : borderSpecifications) {
						if (row < rows) {
							char verticalThere = bs.verticals(row, column);
							if (verticalThere != BorderStyle.NONE) {
								this.verticals[row][column] = verticalThere;
								vFillers[column] |= true;
							}
						}
						if (column < columns) {
							char horizontalThere = bs.horizontals(row, column);
							if (horizontalThere != BorderStyle.NONE) {
								this.horizontals[row][column] = horizontalThere;
								hFillers[row] |= true;
							}
						}
					}
				}
			}

			// Compute corners when horizontals & verticals intersect
			for (int row = 0; row <= rows; row++) {
				for (int column = 0; column <= columns; column++) {
					char left = (column - 1 >= 0) ? horizontals[row][column - 1] : NONE;
					char right = (column < columns) ? horizontals[row][column] : NONE;
					char above = (row - 1 >= 0) ? verticals[row - 1][column] : NONE;
					char below = (row < rows) ? verticals[row][column] : NONE;
					corners[row][column] = BorderStyle.intersection(above, below, left, right);
				}
			}
		}

		private void paintCorner(int row, int column, StringBuilder stringBuilder) {
			if (corners[row][column] != NONE) {
				stringBuilder.append(corners[row][column]);
			} // If there is a border in same row|column, paint filler
			else if (vFillers[column] && hFillers[row]) {
				stringBuilder.append(' ');
			}
		}

		private void paintVertical(int row, int column, StringBuilder stringBuilder) {
			if (verticals[row][column] != NONE) {
				stringBuilder.append(verticals[row][column]);
			}
			else if (vFillers[column]) {
				stringBuilder.append(' ');
			}
		}

		private void paintHorizontal(int row, int column, int width, StringBuilder stringBuilder) {
			if (horizontals[row][column] != NONE) {
				for (int i = 0; i < width; i++) {
					stringBuilder.append(horizontals[row][column]);
				}
			}
			else if (hFillers[row]) {
				for (int i = 0; i < width; i++) {
					stringBuilder.append(' ');
				}
			}
		}

		/**
		 * Return the number of vertical borders, and hence the space consumed by those.
		 */
		public int getNumberOfVerticalBorders() {
			int result = 0;
			for (boolean b : vFillers) {
				if (b) {
					result++;
				}
			}
			return result;
		}
	}

}
