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

import static java.lang.String.format;

import java.awt.geom.Rectangle2D;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
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.Boundary;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.DocumentTree;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.entity.EntityType;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.entity.RedactionEntity;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.AtomicTextBlock;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.TextBlock;
import com.knecon.fforesight.service.layoutparser.processor.model.graph.textblock.TextBlockCollector;
import com.knecon.fforesight.service.layoutparser.processor.utils.RectangleTransformations;

public interface SemanticNode {

    /**
     * Returns the type of this node, such as Section, Paragraph, etc.
     *
     * @return NodeType of this node
     */
    NodeType getType();


    /**
     * Searches all Nodes located underneath this Node in the DocumentTree and concatenates their AtomicTextBlocks into a single TextBlock.
     * So, for a Section all TextBlocks of Subsections, Paragraphs, and Tables are concatenated into a single TextBlock
     * If the Node is a Leaf, the LeafTextBlock will be returned instead.
     *
     * @return TextBlock containing all AtomicTextBlocks that are located under this Node.
     */
    default TextBlock getTextBlock() {

        return streamAllSubNodes().filter(SemanticNode::isLeaf).map(SemanticNode::getTextBlock).collect(new TextBlockCollector());
    }


    /**
     * Any Node maintains its own Set of Entities.
     * This Set contains all Entities whose boundary intersects the boundary of this node.
     *
     * @return Set of all Entities associated with this Node
     */
    Set<RedactionEntity> getEntities();


    /**
     * Each AtomicTextBlock is assigned a page, so to get the pages this node appears on, it collects the PageNodes from each AtomicTextBlock belonging to this node's TextBlock.
     *
     * @return Set of PageNodes this node appears on.
     */
    default Set<Page> getPages() {

        return getTextBlock().getPages();
    }


    default Page getFirstPage() {

        return getTextBlock().getPages().stream().min(Comparator.comparingInt(Page::getNumber)).orElseThrow(() -> new IllegalStateException("SemanticNode has no Page!"));
    }


    /**
     * Each AtomicTextBlock is assigned a page, so to get the pages for this boundary, it collects the PageNodes from each AtomicTextBlock belonging to this node's TextBlock.
     *
     * @return Set of PageNodes this node appears on.
     */
    default Set<Page> getPages(Boundary boundary) {

        if (!getBoundary().contains(boundary)) {
            throw new IllegalArgumentException(format("%s which was used to query for pages is not contained in the %s of this node!", boundary, getBoundary()));
        }
        return getTextBlock().getPages(boundary);
    }


    default boolean isOnPage(int pageNumber) {

        return getPages().stream().anyMatch(page -> page.getNumber() == pageNumber);
    }


    /**
     * Returns the DocumentTree Object.
     *
     * @return the DocumentTree of the Document this node belongs to
     */
    DocumentTree getDocumentTree();


    /**
     * The id is a List of Integers uniquely identifying this node in the DocumentTree.
     *
     * @return the DocumentTree ID
     */
    List<Integer> getTreeId();


    /**
     * This should only be used during graph construction.
     *
     * @param tocId List of Integers
     */
    void setTreeId(List<Integer> tocId);


    /**
     * Traverses the Tree up, until it hits a Headline or hits a Section which will then return the first Headline from its children.
     * Throws NotFoundException if no Headline is found this way
     *
     * @return First Headline found
     */
    default Headline getHeadline() {

        return getParent().getHeadline();
    }


    /**
     * Checks if its TocId has a length greater than zero.
     *
     * @return boolean indicating whether this Node has a Parent in the DocumentTree
     */
    default boolean hasParent() {

        return getDocumentTree().hasParentById(getTreeId());
    }


    /**
     * @return The SemanticNode representing the Parent in the DocumentTree
     * throws NotFoundException, when no parent is present
     */
    default SemanticNode getParent() {

        return getDocumentTree().getParentEntryById(getTreeId()).getNode();
    }


    /**
     * @return The SemanticNode which is directly underneath the document and also under which this node is.
     * if this is the highest child node or the document itself, it returns itself.
     */
    default SemanticNode getHighestParent() {

        return getDocumentTree().getHighestParentById(getTreeId());
    }


    /**
     * Leaf means a SemanticNode has direct access to a TextBlock, by default this is false and must be overridden.
     * Currently only Sections, Images, and Tables are not leaves.
     * A TableCell might be a leaf depending on its area compared to the page.
     *
     * @return boolean, indicating if a Node has direct access to a TextBlock
     */
    default boolean isLeaf() {

        return false;
    }


    /**
     * Leaf means a SemanticNode has direct access to a TextBlock, by default this is false and must be overridden.
     * Currently only Sections and Tables are no leaves.
     *
     * @return AtomicTextBlock
     */
    default TextBlock getLeafTextBlock() {

        throw new UnsupportedOperationException("Only leaf Nodes have access to LeafTextBlocks!");
    }


    /**
     * Should only be used during construction of the Graph. Sets the LeafTextBlock of this SemanticNode.
     *
     * @param textBlock the TextBlock to set as the LeafTextBlock of this SemanticNode
     */
    default void setLeafTextBlock(TextBlock textBlock) {

        throw new UnsupportedOperationException();
    }


    /**
     * Checks whether this SemanticNode has any Entity with EntityType.ENTITY of the provided type.
     *
     * @param type string representing the type of entity to check for
     * @return true, if this SemanticNode has at least one Entity of the provided type
     */
    default boolean hasEntitiesOfType(String type) {

        return getEntities().stream().filter(entity -> entity.getEntityType().equals(EntityType.ENTITY)).anyMatch(redactionEntity -> redactionEntity.getType().equals(type));
    }


    /**
     * Returns a List of Entities in this SemanticNode which are of the provided type such as "CBI_author".
     *
     * @param type string representing the type of entities to return
     * @return List of RedactionEntities of any the type
     */
    default List<RedactionEntity> getEntitiesOfType(String type) {

        return getEntities().stream().filter(redactionEntity -> redactionEntity.getType().equals(type)).toList();
    }


    /**
     * Returns a List of Entities in this SemanticNode which have any of the provided types such as "CBI_author".
     *
     * @param types A list of strings representing the types of entities to return
     * @return List of RedactionEntities of any provided type
     */
    default List<RedactionEntity> getEntitiesOfType(List<String> types) {

        return getEntities().stream().filter(redactionEntity -> redactionEntity.isAnyType(types)).toList();
    }


    /**
     * Each AtomicTextBlock has an index on its page, this returns the number of the first AtomicTextBlock underneath this node.
     * If this node does not have any AtomicTexBlocks underneath it, e.g. an empty TableCell. It returns -1.
     *
     * @return Integer representing the number on the page
     */
    default Integer getNumberOnPage() {

        TextBlock textBlock = getTextBlock();
        if (!textBlock.getAtomicTextBlocks().isEmpty()) {
            return getTextBlock().getAtomicTextBlocks().get(0).getNumberOnPage();
        } else {
            return -1;
        }
    }


    /**
     * Checks if the SemanticNode contains any text.
     *
     * @return true, if this node's TextBlock is not empty
     */
    default boolean hasText() {

        return !getTextBlock().isEmpty();
    }


    /**
     * Checks whether this SemanticNode contains the provided String.
     *
     * @param string A String which the TextBlock might contain
     * @return true, if this node's TextBlock contains the string
     */
    default boolean containsString(String string) {

        return getTextBlock().getSearchText().contains(string);
    }


    /**
     * Checks whether this SemanticNode contains all the provided Strings.
     *
     * @param strings A List of Strings which the TextBlock might contain
     * @return true, if this node's TextBlock contains all strings
     */
    default boolean containsStrings(List<String> strings) {

        return strings.stream().allMatch(this::containsString);
    }


    /**
     * Checks whether this SemanticNode contains all the provided Strings ignoring case.
     *
     * @param string A String which the TextBlock might contain
     * @return true, if this node's TextBlock contains the string ignoring case
     */
    default boolean containsStringIgnoreCase(String string) {

        return getTextBlock().getSearchText().toLowerCase(Locale.ROOT).contains(string.toLowerCase(Locale.ROOT));
    }


    /**
     * Checks whether this SemanticNode contains any of the provided Strings.
     *
     * @param strings A List of Strings which the TextBlock might contain
     * @return true, if this node's TextBlock contains any of the strings
     */
    default boolean containsAnyString(List<String> strings) {

        return strings.stream().anyMatch(this::containsString);
    }


    /**
     * Checks whether this SemanticNode contains any of the provided Strings ignoring case.
     *
     * @param strings A List of Strings which the TextBlock might contain
     * @return true, if this node's TextBlock contains any of the strings
     */
    default boolean containsAnyStringIgnoreCase(List<String> strings) {

        return strings.stream().anyMatch(this::containsStringIgnoreCase);
    }


    /**
     * This function is used during insertion of EntityNodes into the graph, it checks if the boundary of the RedactionEntity intersects or even contains the RedactionEntity.
     * It sets the fields accordingly and recursively calls this function on all its children.
     *
     * @param redactionEntity RedactionEntity, which is being inserted into the graph
     */
    default void addThisToEntityIfIntersects(RedactionEntity redactionEntity) {

        TextBlock textBlock = getTextBlock();
        if (textBlock.getBoundary().intersects(redactionEntity.getBoundary())) {
            if (textBlock.containsBoundary(redactionEntity.getBoundary())) {
                redactionEntity.setDeepestFullyContainingNode(this);
            }

            redactionEntity.addIntersectingNode(this);
            streamChildren().filter(semanticNode -> semanticNode.getBoundary().intersects(redactionEntity.getBoundary()))
                    .forEach(node -> node.addThisToEntityIfIntersects(redactionEntity));
        }
    }


    /**
     * returns the set of layoutengines.
     *
     * @return set of layoutengines.
     */
    Set<LayoutEngine> getEngines();


    /**
     * adds a layoutengine to the set.
     */
    default void addEngine(LayoutEngine engine) {

        getEngines().add(engine);
    }


    /**
     * Streams all children located directly underneath this node in the DocumentTree.
     *
     * @return Stream of all children
     */
    default Stream<SemanticNode> streamChildren() {

        return getDocumentTree().childNodes(getTreeId());
    }


    /**
     * Streams all children located directly underneath this node in the DocumentTree of the provided type.
     *
     * @return Stream of all children
     */
    default Stream<SemanticNode> streamChildrenOfType(NodeType nodeType) {

        return getDocumentTree().childNodesOfType(getTreeId(), nodeType);
    }


    /**
     * Recursively streams all SemanticNodes located underneath this node in the DocumentTree in order.
     *
     * @return Stream of all SubNodes
     */
    default Stream<SemanticNode> streamAllSubNodes() {

        return getDocumentTree().allSubEntriesInOrder(getTreeId()).map(DocumentTree.Entry::getNode);
    }


    /**
     * Recursively streams all SemanticNodes of the provided type located underneath this node in the DocumentTree in order.
     *
     * @return Stream of all SubNodes
     */
    default Stream<SemanticNode> streamAllSubNodesOfType(NodeType nodeType) {

        return getDocumentTree().allSubEntriesInOrder(getTreeId()).filter(entry -> entry.getType().equals(nodeType)).map(DocumentTree.Entry::getNode);
    }


    /**
     * The Boundary is the start and end string offsets in the reading order of the document.
     *
     * @return Boundary of this Node's TextBlock
     */
    default Boundary getBoundary() {

        return getTextBlock().getBoundary();
    }


    /**
     * If this Node is a Leaf it will calculate the boundingBox of its LeafTextBlock, otherwise it will calculate the Union of the BoundingBoxes of all its Children.
     * If called on the Document, it will return the cropbox of each page
     *
     * @return Rectangle2D fully encapsulating this Node for each page.
     */
    default Map<Page, Rectangle2D> getBBox() {

        if (isLeaf()) {
            return getBBoxFromLeafTextBlock();
        }

        return getBBoxFromChildren();
    }


    /**
     * Checks whether the Bounding Box of this SemanticNode contains the provided rectangle on the provided page.
     *
     * @param rectangle2D The rectangle to check if it is contained
     * @param pageNumber  The Page number on which the rectangle should be checked
     * @return boolean
     */
    default boolean containsRectangle(Rectangle2D rectangle2D, Integer pageNumber) {

        Page helperPage = Page.builder().number(pageNumber).build();
        if (!getPages().contains(helperPage)) {
            return false;
        }
        return getBBox().get(helperPage).contains(rectangle2D);
    }


    /**
     * TODO: this produces unwanted results for sections spanning multiple columns.
     * Computes the Union of the bounding boxes of all children recursively.
     *
     * @return The union of the BoundingBoxes of all children
     */
    private Map<Page, Rectangle2D> getBBoxFromChildren() {

        Map<Page, Rectangle2D> bBoxPerPage = new HashMap<>();
        List<Map<Page, Rectangle2D>> childrenBBoxes = streamChildren().map(SemanticNode::getBBox).toList();
        Set<Page> pages = childrenBBoxes.stream().flatMap(map -> map.keySet().stream()).collect(Collectors.toSet());
        for (Page page : pages) {
            Rectangle2D bBoxOnPage = childrenBBoxes.stream()
                    .filter(childBboxPerPage -> childBboxPerPage.containsKey(page))
                    .map(childBboxPerPage -> childBboxPerPage.get(page))
                    .collect(RectangleTransformations.collectBBox());
            bBoxPerPage.put(page, bBoxOnPage);
        }
        return bBoxPerPage;
    }


    /**
     * @return The union of all BoundingBoxes of the TextBlock of this node
     */
    private Map<Page, Rectangle2D> getBBoxFromLeafTextBlock() {

        Map<Page, Rectangle2D> bBoxPerPage = new HashMap<>();
        Map<Page, List<AtomicTextBlock>> atomicTextBlockPerPage = getTextBlock().getAtomicTextBlocks().stream().collect(Collectors.groupingBy(AtomicTextBlock::getPage));
        atomicTextBlockPerPage.forEach((page, atbs) -> bBoxPerPage.put(page, RectangleTransformations.bBoxUnionAtomicTextBlock(atbs)));
        return bBoxPerPage;
    }

}