<?php
/**
 * @package dompdf
 * @link    http://dompdf.github.com/
 * @author  Benj Carson <benjcarson@digitaljunkies.ca>
 * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
 */
namespace PrimerDompdf\FrameDecorator;

use PrimerDompdf\Cellmap;
use DOMNode;
use PrimerDompdf\Css\Style;
use PrimerDompdf\Dompdf;
use PrimerDompdf\Frame;

/**
 * Decorates Frames for table layout
 *
 * @package dompdf
 */
class Table extends AbstractFrameDecorator
{
    public static $VALID_CHILDREN = Style::TABLE_INTERNAL_TYPES;

    public static $ROW_GROUPS = [
        "table-row-group",
        "table-header-group",
        "table-footer-group"
    ];

    /**
     * The Cellmap object for this table.  The cellmap maps table cells
     * to rows and columns, and aids in calculating column widths.
     *
     * @var Cellmap
     */
    protected $_cellmap;

    /**
     * The minimum width of the table, in pt
     *
     * @var float
     */
    protected $_min_width;

    /**
     * The maximum width of the table, in pt
     *
     * @var float
     */
    protected $_max_width;

    /**
     * Table header rows.  Each table header is duplicated when a table
     * spans pages.
     *
     * @var TableRowGroup[]
     */
    protected $_headers;

    /**
     * Table footer rows.  Each table footer is duplicated when a table
     * spans pages.
     *
     * @var TableRowGroup[]
     */
    protected $_footers;

    /**
     * Class constructor
     *
     * @param Frame $frame the frame to decorate
     * @param Dompdf $dompdf
     */
    public function __construct(Frame $frame, Dompdf $dompdf)
    {
        parent::__construct($frame, $dompdf);
        $this->_cellmap = new Cellmap($this);

        if ($frame->get_style()->table_layout === "fixed") {
            $this->_cellmap->set_layout_fixed(true);
        }

        $this->_min_width = null;
        $this->_max_width = null;
        $this->_headers = [];
        $this->_footers = [];
    }

    public function reset()
    {
        parent::reset();
        $this->_cellmap->reset();
        $this->_min_width = null;
        $this->_max_width = null;
        $this->_headers = [];
        $this->_footers = [];
        $this->_reflower->reset();
    }

    //........................................................................

    /**
     * Split the table at $row.  $row and all subsequent rows will be
     * added to the clone.  This method is overridden in order to remove
     * frames from the cellmap properly.
     */
    public function split(?Frame $child = null, bool $page_break = false, bool $forced = false): void
    {
        if (is_null($child)) {
            parent::split($child, $page_break, $forced);
            return;
        }

        // If $child is a header or if it is the first non-header row, do
        // not duplicate headers, simply move the table to the next page.
        if (count($this->_headers)
            && !in_array($child, $this->_headers, true)
            && !in_array($child->get_prev_sibling(), $this->_headers, true)
        ) {
            $first_header = null;

            // Insert copies of the table headers before $child
            foreach ($this->_headers as $header) {

                $new_header = $header->deep_copy();

                if (is_null($first_header)) {
                    $first_header = $new_header;
                }

                $this->insert_child_before($new_header, $child);
            }

            parent::split($first_header, $page_break, $forced);

        } elseif (in_array($child->get_style()->display, self::$ROW_GROUPS, true)) {

            // Individual rows should have already been handled
            parent::split($child, $page_break, $forced);

        } else {

            $iter = $child;

            while ($iter) {
                $this->_cellmap->remove_row($iter);
                $iter = $iter->get_next_sibling();
            }

            parent::split($child, $page_break, $forced);
        }
    }

    public function copy(DOMNode $node)
    {
        $deco = parent::copy($node);

        // In order to keep columns' widths through pages
        $deco->_cellmap->set_columns($this->_cellmap->get_columns());
        $deco->_cellmap->lock_columns();

        return $deco;
    }

    /**
     * Static function to locate the parent table of a frame
     *
     * @param Frame $frame
     *
     * @return Table the table that is an ancestor of $frame
     */
    public static function find_parent_table(Frame $frame)
    {
        while ($frame = $frame->get_parent()) {
            if ($frame->is_table()) {
                break;
            }
        }

        return $frame;
    }

    /**
     * Return this table's Cellmap
     *
     * @return Cellmap
     */
    public function get_cellmap()
    {
        return $this->_cellmap;
    }

    /**
     * Return the minimum width of this table
     *
     * @return float
     */
    public function get_min_width()
    {
        return $this->_min_width;
    }

    /**
     * Return the maximum width of this table
     *
     * @return float
     */
    public function get_max_width()
    {
        return $this->_max_width;
    }

    /**
     * Set the minimum width of the table
     *
     * @param float $width the new minimum width
     */
    public function set_min_width($width)
    {
        $this->_min_width = $width;
    }

    /**
     * Set the maximum width of the table
     *
     * @param float $width the new maximum width
     */
    public function set_max_width($width)
    {
        $this->_max_width = $width;
    }

    //........................................................................

    /**
     * Check for text nodes between valid table children that only contain white
     * space, except if white space is to be preserved.
     *
     * @param AbstractFrameDecorator $frame
     *
     * @return bool
     */
    private function isEmptyTextNode(AbstractFrameDecorator $frame): bool
    {
        // This is based on the white-space pattern in `FrameReflower\Text`,
        // i.e. only match on collapsible white space
        $wsPattern = '/^[^\S\xA0\x{202F}\x{2007}]*$/u';
        $validChildOrNull = function ($frame) {
            return $frame === null
                || in_array($frame->get_style()->display, self::$VALID_CHILDREN, true);
        };

        return $frame->is_text_node() && !$frame->is_pre()
            && preg_match($wsPattern, $frame->get_text())
            && $validChildOrNull($frame->get_prev_sibling())
            && $validChildOrNull($frame->get_next_sibling());
    }

    /**
     * Restructure tree so that the table has the correct structure. Misplaced
     * children are appropriately wrapped in anonymous row groups, rows, and
     * cells.
     *
     * https://www.w3.org/TR/CSS21/tables.html#anonymous-boxes
     */
    public function normalize(): void
    {
        $column_caption = ["table-column-group", "table-column", "table-caption"];
        $children = iterator_to_array($this->get_children());
        $tbody = null;

        foreach ($children as $child) {
            $display = $child->get_style()->display;

            if (in_array($display, self::$ROW_GROUPS, true)) {
                // Reset anonymous tbody
                $tbody = null;

                // Add headers and footers
                if ($display === "table-header-group") {
                    $this->_headers[] = $child;
                } elseif ($display === "table-footer-group") {
                    $this->_footers[] = $child;
                }
                continue;
            }

            if (in_array($display, $column_caption, true)) {
                continue;
            }

            // Remove empty text nodes between valid children
            if ($this->isEmptyTextNode($child)) {
                $this->remove_child($child);
                continue;
            }

            // Catch consecutive misplaced frames within a single anonymous group
            if ($tbody === null) {
                $tbody = $this->create_anonymous_child("tbody", "table-row-group");
                $this->insert_child_before($tbody, $child);
            }

            $tbody->append_child($child);
        }

        // Handle empty table: Make sure there is at least one row group
        if (!$this->get_first_child()) {
            $tbody = $this->create_anonymous_child("tbody", "table-row-group");
            $this->append_child($tbody);
        }

        foreach ($this->get_children() as $child) {
            $display = $child->get_style()->display;

            if (in_array($display, self::$ROW_GROUPS, true)) {
                $this->normalizeRowGroup($child);
            }
        }
    }

    private function normalizeRowGroup(AbstractFrameDecorator $frame): void
    {
        $children = iterator_to_array($frame->get_children());
        $tr = null;

        foreach ($children as $child) {
            $display = $child->get_style()->display;

            if ($display === "table-row") {
                // Reset anonymous tr
                $tr = null;
                continue;
            }

            // Remove empty text nodes between valid children
            if ($this->isEmptyTextNode($child)) {
                $frame->remove_child($child);
                continue;
            }

            // Catch consecutive misplaced frames within a single anonymous row
            if ($tr === null) {
                $tr = $frame->create_anonymous_child("tr", "table-row");
                $frame->insert_child_before($tr, $child);
            }

            $tr->append_child($child);
        }

        // Handle empty row group: Make sure there is at least one row
        if (!$frame->get_first_child()) {
            $tr = $frame->create_anonymous_child("tr", "table-row");
            $frame->append_child($tr);
        }

        foreach ($frame->get_children() as $child) {
            $this->normalizeRow($child);
        }
    }

    private function normalizeRow(AbstractFrameDecorator $frame): void
    {
        $children = iterator_to_array($frame->get_children());
        $td = null;

        foreach ($children as $child) {
            $display = $child->get_style()->display;

            if ($display === "table-cell") {
                // Reset anonymous td
                $td = null;
                continue;
            }

            // Remove empty text nodes between valid children
            if ($this->isEmptyTextNode($child)) {
                $frame->remove_child($child);
                continue;
            }

            // Catch consecutive misplaced frames within a single anonymous cell
            if ($td === null) {
                $td = $frame->create_anonymous_child("td", "table-cell");
                $frame->insert_child_before($td, $child);
            }

            $td->append_child($child);
        }

        // Handle empty row: Make sure there is at least one cell
        if (!$frame->get_first_child()) {
            $td = $frame->create_anonymous_child("td", "table-cell");
            $frame->append_child($td);
        }
    }

    //........................................................................

    /**
     * @deprecated
     */
    public function normalise()
    {
        $this->normalize();
    }

    /**
     * Moves the specified frame and it's corresponding node outside of
     * the table.
     *
     * @deprecated
     * @param Frame $frame the frame to move
     */
    public function move_after(Frame $frame)
    {
        $this->get_parent()->insert_child_after($frame, $this);
    }
}
