package org.vandeseer.easytable;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import lombok.experimental.SuperBuilder;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.vandeseer.easytable.drawing.Drawer;
import org.vandeseer.easytable.drawing.DrawingContext;
import org.vandeseer.easytable.structure.Row;
import org.vandeseer.easytable.structure.Table;
import org.vandeseer.easytable.structure.cell.AbstractCell;

import java.awt.geom.Point2D;
import java.io.IOException;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.function.BiConsumer;
import java.util.function.Supplier;

import static org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode.APPEND;

@SuperBuilder(toBuilder = true)
public class TableDrawer {

    protected final Table table;

    @Setter
    @Accessors(chain = true, fluent = true)
    protected PDPageContentStream contentStream;

    @Setter
    @Accessors(chain = true, fluent = true)
    protected PDPage page;

    @Setter
    @Accessors(chain = true, fluent = true)
    protected float startX;

    @Setter
    @Accessors(chain = true, fluent = true)
    protected float startY;

    protected float endY;

    @Getter
    private float finalY;

    protected final List<BiConsumer<Drawer, DrawingContext>> drawerList = new LinkedList<>();
    {
        this.drawerList.add((drawer, drawingContext) -> {
            drawer.drawBackground(drawingContext);
            drawer.drawContent(drawingContext);
        });
        this.drawerList.add(Drawer::drawBorders);
    }

    public static class PageData {

        public final int firstRowOnPage;
        public final int firstRowOnNextPage;

        public PageData(int firstRowOnPage, int firstRowOnNextPage) {
            this.firstRowOnPage = firstRowOnPage;
            this.firstRowOnNextPage = firstRowOnNextPage;
        }
    }

    public void draw() {
        drawPage(new PageData(0, table.getRows().size()));
    }

    protected void drawPage(PageData pageData) {
        drawerList.forEach(drawer ->
            drawWithFunction(pageData, new Point2D.Float(this.startX, this.startY), drawer)
        );
    }

    private Queue<PageData> computeRowsOnPagesWithNewPageStartOf(float yOffsetOnNewPage) {
        final Queue<PageData> dataForPages = new LinkedList<>();

        float y = startY;

        int firstRowOnPage = 0;
        int lastRowOnPage = 0;

        for (final Row row : table.getRows()) {
            if (isRowTooHighToBeDrawnOnPage(row, yOffsetOnNewPage)) {
                throw new RowIsTooHighException("There is a row that is too high to be drawn on a single page");
            }

            if (isNotDrawableOnPage(y, row)) {
                dataForPages.add(new PageData(firstRowOnPage, lastRowOnPage));
                y = yOffsetOnNewPage;
                firstRowOnPage = lastRowOnPage;
            }

            y -= row.getHeight();
            lastRowOnPage++;
        }

        // add the remaining page data
        dataForPages.add(new PageData(firstRowOnPage, lastRowOnPage));

        return dataForPages;
    }

    private boolean isRowTooHighToBeDrawnOnPage(Row row, float yOffsetOnNewPage) {
        return row.getHeight() > (yOffsetOnNewPage - endY);
    }

    public void draw(Supplier<PDDocument> documentSupplier, Supplier<PDPage> pageSupplier, float yOffset) throws IOException {
        final PDDocument document = documentSupplier.get();

        // We create one throwaway page to be able to calculate the page data upfront
        float startOnNewPage = pageSupplier.get().getMediaBox().getHeight() - yOffset;
        final Queue<PageData> pageDataQueue = computeRowsOnPagesWithNewPageStartOf(startOnNewPage);

        for (int i = 0; !pageDataQueue.isEmpty(); i++) {
            final PDPage pageToDrawOn;

            if (i > 0 || document.getNumberOfPages() == 0) {
                pageToDrawOn = pageSupplier.get();
                document.addPage(pageToDrawOn);
            } else {
                pageToDrawOn = document.getPage(document.getNumberOfPages() - 1);
            }

            try (final PDPageContentStream newPageContentStream = new PDPageContentStream(document, pageToDrawOn, APPEND, false)) {
                this.contentStream(newPageContentStream)
                        .page(pageToDrawOn)
                        .drawPage(pageDataQueue.poll());
            }

            startY(pageToDrawOn.getMediaBox().getHeight() - yOffset);
        }
    }

    protected void drawWithFunction(PageData pageData, Point2D.Float startingPoint, BiConsumer<Drawer, DrawingContext> consumer) {
        float y = startingPoint.y;

        for (int rowIndex = pageData.firstRowOnPage; rowIndex < pageData.firstRowOnNextPage; rowIndex++) {
            final Row row = table.getRows().get(rowIndex);
            y -= row.getHeight();
            drawRow(new Point2D.Float(startingPoint.x, y), row, rowIndex, consumer);
            finalY = y;
        }
    }

    protected void drawRow(Point2D.Float start, Row row, int rowIndex, BiConsumer<Drawer, DrawingContext> consumer) {
        float x = start.x;

        int columnCounter = 0;
        for (AbstractCell cell : row.getCells()) {

            while (table.isRowSpanAt(rowIndex, columnCounter)) {
                x += table.getColumns().get(columnCounter).getWidth();
                columnCounter++;
            }

            // This is the interesting part :)
            consumer.accept(
                    cell.getDrawer(),
                    new DrawingContext(
                            contentStream, page, new Point2D.Float(x, start.y)
                    )
            );

            x += cell.getWidth();
            columnCounter += cell.getColSpan();
        }
    }

    private boolean isNotDrawableOnPage(float startY, Row row) {
        return startY - getHighestCellOf(row) < endY;
    }

    private Float getHighestCellOf(Row row) {
        return row.getCells().stream()
                .map(AbstractCell::getHeight)
                .max(Comparator.naturalOrder())
                .orElse(row.getHeight());
    }

}