package com.knecon.fforesight.service.layoutparser.processor.model.graph.nodes;

import static java.lang.String.format;

import java.awt.geom.Rectangle2D;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import com.knecon.fforesight.service.layoutparser.internal.api.data.redaction.LayoutEngine;
import com.knecon.fforesight.service.layoutparser.internal.api.data.redaction.NodeType;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.DocumentTree;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.entity.RedactionEntity;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.TextBlock;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.TextBlockCollector;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.FieldDefaults;

@Data
@Builder
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class Table implements SemanticNode {

    @Builder.Default
    Set<LayoutEngine> engines = new HashSet<>(Set.of(LayoutEngine.ALGORITHM));
    List<Integer> treeId;
    DocumentTree documentTree;

    int numberOfRows;
    int numberOfCols;
    TextBlock textBlock;

    @Builder.Default
    @EqualsAndHashCode.Exclude
    Set<RedactionEntity> entities = new HashSet<>();

    @EqualsAndHashCode.Exclude
    Map<Page, Rectangle2D> bBoxCache;

    /**
     * Streams all entities in this table, that appear in a row, which contains any of the provided strings.
     *
     * @param strings Strings to check whether a row contains them
     * @return Stream of all entities in this table, that appear in a row, which contains any of the provided strings
     */
    public Stream<RedactionEntity> streamEntitiesWhereRowContainsStringsIgnoreCase(List<String> strings) {

        return IntStream.range(0, numberOfRows).boxed()
                .filter(row -> rowContainsStringsIgnoreCase(row, strings))
                .flatMap(this::streamRow)
                .map(TableCell::getEntities)
                .flatMap(Collection::stream);
    }


    /**
     * Checks whether the specified row contains all the provided strings.
     *
     * @param row     the row to check as an Integer, must be smaller than numberOfRows
     * @param strings a list of strings to check for
     * @return true, if all strings appear in the provided row
     */
    public boolean rowContainsStringsIgnoreCase(Integer row, List<String> strings) {

        String rowText = streamRow(row).map(TableCell::getTextBlock)
                .collect(new TextBlockCollector()).getSearchText().toLowerCase(Locale.ROOT);
        return strings.stream()
                .map(String::toLowerCase)
                .allMatch(rowText::contains);
    }


    /**
     * Streams all entities which appear in a row where at least one cell has the provided header and the provided value.
     *
     * @param header the header value to search for
     * @param value  the string which the table cell should contain
     * @return a stream of all entities, which appear in a row where at least one cell has the provided header and the provided value.
     */
    public Stream<RedactionEntity> streamEntitiesWhereRowHasHeaderAndValue(String header, String value) {

        List<Integer> vertebrateStudyCols = streamHeaders().filter(headerNode -> headerNode.containsString(header))
                .map(TableCell::getCol)
                .toList();
        return streamTableCells().filter(tableCellNode -> vertebrateStudyCols.stream()
                        .anyMatch(vertebrateStudyCol -> getCell(tableCellNode.getRow(), vertebrateStudyCol).containsString(value)))
                .map(TableCell::getEntities)
                .flatMap(Collection::stream);
    }


    /**
     * Streams all entities which appear in a row where at least one cell has the provided header and any provided value.
     *
     * @param header the header value to search for
     * @param values the strings which the table cell should contain
     * @return a stream of all entities, which appear in a row where at least one cell has the provided header and any provided value.
     */
    public Stream<RedactionEntity> streamEntitiesWhereRowHasHeaderAndAnyValue(String header, List<String> values) {

        List<Integer> colsWithHeader = streamHeaders().filter(headerNode -> headerNode.containsString(header))
                .map(TableCell::getCol)
                .toList();
        return streamTableCells().filter(tableCellNode -> colsWithHeader.stream()
                        .anyMatch(colWithHeader -> getCell(tableCellNode.getRow(), colWithHeader).containsAnyString(values)))
                .map(TableCell::getEntities)
                .flatMap(Collection::stream);
    }


    /**
     * Streams all entities in this table, that appear in a row, which contains at least one entity with any of the provided types.
     *
     * @param types type strings to check whether a row contains an entity like them
     * @return Stream of all entities in this table, that appear in a row, which contains at least one entity with any of the provided types.
     */
    public Stream<RedactionEntity> streamEntitiesWhereRowContainsEntitiesOfType(List<String> types) {

        List<Integer> rowsWithEntityOfType = IntStream.range(0, numberOfRows).boxed()
                .filter(rowNumber -> streamEntityTypesInRow(rowNumber).anyMatch(existingType -> types.stream()
                        .anyMatch(typeToCheck -> typeToCheck.equals(existingType))))
                .toList();

        return rowsWithEntityOfType.stream()
                .flatMap(this::streamRow)
                .map(TableCell::getEntities)
                .flatMap(Collection::stream);
    }


    /**
     * Streams all entities in this table, that appear in a row, which does not contain any entity with any of the provided types.
     *
     * @param types type strings to check whether a row doesn't contain an entity like it
     * @return Stream of all entities in this table, that appear in a row, which does not contain any entity with any of the provided types.
     */
    public Stream<RedactionEntity> streamEntitiesWhereRowContainsNoEntitiesOfType(List<String> types) {

        List<Integer> rowsWithNoEntityOfType = IntStream.range(0, numberOfRows).boxed()
                .filter(rowNumber -> streamEntityTypesInRow(rowNumber).noneMatch(existingType -> types.stream()
                        .anyMatch(typeToCheck -> typeToCheck.equals(existingType))))
                .toList();

        return rowsWithNoEntityOfType.stream()
                .flatMap(this::streamRow)
                .map(TableCell::getEntities)
                .flatMap(Collection::stream);
    }


    private Stream<String> streamEntityTypesInRow(Integer rowNumber) {

        return streamRow(rowNumber).map(TableCell::getEntities)
                .flatMap(Collection::stream)
                .map(RedactionEntity::getType)
                .distinct();
    }


    /**
     * Returns a TableCell at the provided row and column location.
     *
     * @param row int representing the row, must be smaller than numberOfRows
     * @param col int representing the col, must be smaller than numberOfCols
     * @return TableCell at the provided location in the table
     */
    public TableCell getCell(int row, int col) {

        if (numberOfRows - row < 0 || numberOfCols - col < 0) {
            throw new IllegalArgumentException(format("row %d, col %d is out of bounds for number of rows of %d and number of cols %d", row, col, numberOfRows, numberOfCols));
        }
        int idx = row * numberOfCols + col;
        return (TableCell) documentTree.getEntryById(treeId).getChildren()
                .get(idx).getNode();
    }


    /**
     * Streams all TableCells in this Table row-wise.
     *
     * @return Stream of all TableCells
     */
    public Stream<TableCell> streamTableCells() {

        return streamChildrenOfType(NodeType.TABLE_CELL).map(node -> (TableCell) node);
    }


    /**
     * Streams all TableCells in this Table which have the provided header row-wise.
     *
     * @return Stream of all TableCells which have the provided header
     */
    public Stream<TableCell> streamTableCellsWithHeader(String header) {

        return streamHeaders().filter(tableCellNode -> tableCellNode.getTextBlock().getSearchText().contains(header))
                .map(TableCell::getCol)
                .flatMap(this::streamCol)
                .filter(tableCellNode -> !tableCellNode.isHeader());
    }


    /**
     * Streams all TableCells belonging to the provided column from top down.
     *
     * @param col int representing the column
     * @return Stream of all TableCell in the provided column
     */
    public Stream<TableCell> streamCol(int col) {

        return IntStream.range(0, numberOfRows).boxed()
                .map(row -> getCell(row, col));
    }


    /**
     * Streams all TableCells belonging to the provided row from left to right.
     *
     * @param row int representing the row
     * @return Stream of all TableCell in the provided row
     */
    public Stream<TableCell> streamRow(int row) {

        return IntStream.range(0, numberOfCols).boxed()
                .map(col -> getCell(row, col));
    }


    /**
     * Streams all TableCells row-wise and filters them with header == true.
     *
     * @return Stream of all TableCells with header == true
     */
    public Stream<TableCell> streamHeaders() {

        return streamTableCells().filter(TableCell::isHeader);
    }


    /**
     * Streams all TableCells of the provided row and column and filters them with header == true.
     *
     * @param row int representing the row
     * @param col int representing the column
     * @return Stream of all TableCells with header == true in the provided row or col
     */
    public Stream<TableCell> streamHeadersForCell(int row, int col) {

        return Stream.concat(streamRow(row), streamCol(col))
                .filter(TableCell::isHeader);
    }


    /**
     * Streams all Headers and checks if any equal the provided string.
     *
     * @param header string to check the headers for
     * @return true, if at least one header equals the provided string
     */
    public boolean hasHeader(String header) {

        return streamHeaders().anyMatch(tableCellNode -> tableCellNode.getTextBlock().getSearchText().strip().equals(header));
    }


    /**
     * Checks if this table has a column with the provided header and any of the table cells in that column contain the provided value.
     *
     * @param header string to find header cells
     * @param value  string to check cells with provided header
     * @return true, if this table has a column with the provided header and any of the table cells in that column contain the provided value
     */
    public boolean hasRowWithHeaderAndValue(String header, String value) {

        return streamTableCellsWithHeader(header).anyMatch(tableCellNode -> tableCellNode.containsString(value));
    }


    /**
     * Checks if this table has a column with the provided header and any of the table cells in that column contains any of the provided values.
     *
     * @param header string to find header cells
     * @param values List of strings to check cells with provided header
     * @return true, if this table has a column with the provided header and any of the table cells in that column contains any of the provided values.
     */
    public boolean hasRowWithHeaderAndAnyValue(String header, List<String> values) {

        return streamTableCellsWithHeader(header).anyMatch(tableCellNode -> tableCellNode.containsAnyString(values));
    }


    /**
     * Finds all entities of the provided type, which appear in the same row that the provided entity appears in.
     *
     * @param type            the type of entities to search for
     * @param redactionEntity the entity, which appears in the row to search
     * @return List of all entities of the provided type, which appear in the same row that the provided entity appears in.
     */
    public List<RedactionEntity> getEntitiesOfTypeInSameRow(String type, RedactionEntity redactionEntity) {

        return redactionEntity.getIntersectingNodes()
                .stream()
                .filter(node -> node instanceof TableCell)
                .map(node -> (TableCell) node)
                .flatMap(tableCellNode -> streamRow(tableCellNode.getRow()))
                .map(cell -> cell.getEntitiesOfType(type))
                .flatMap(Collection::stream)
                .toList();
    }


    @Override
    public NodeType getType() {

        return NodeType.TABLE;
    }


    @Override
    public TextBlock getTextBlock() {

        if (textBlock == null) {
            textBlock = SemanticNode.super.getTextBlock();
        }
        return textBlock;
    }


    @Override
    public String toString() {

        return treeId.toString() + ": " + NodeType.TABLE + ": #cols: " + numberOfCols + ", #rows: " + numberOfRows + ", " + this.getTextBlock().buildSummary();
    }


    @Override
    public Map<Page, Rectangle2D> getBBox() {

        if (bBoxCache == null) {
            bBoxCache = SemanticNode.super.getBBox();
        }
        return bBoxCache;
    }

}
