<?php

namespace AmpProject\Cli;

use AmpProject\Exception\Cli\InvalidColumnFormat;

/**
 * This file is adapted from the splitbrain\php-cli library, which is authored by Andreas Gohr <andi@splitbrain.org> and
 * licensed under the MIT license.
 *
 * Source: https://github.com/splitbrain/php-cli/blob/8c2c001b1b55d194402cf18aad2757049ac6d575/src/TableFormatter.php
 */

/**
 * Output text in multiple columns.
 *
 * @package ampproject/amp-toolbox
 */
class TableFormatter
{
    /**
     * Border between columns.
     *
     * @var string
     */
    protected $border = ' ';

    /**
     * Padding around the border.
     *
     * @var int
     */
    protected $padding = 0;

    /**
     * The terminal width in characters.
     *
     * Falls back to 74 characters if it cannot be detected.
     *
     * @var int
     */
    protected $maxWidth = 74;

    /**
     * Instance of the Colors helper object.
     *
     * @var Colors
     */
    protected $colors;

    /**
     * Width of each column size based on the content length.
     *
     * @var array
     */
    protected $tableColumnWidths = [];

    /**
     * Maximum length of the table content.
     *
     * @var int
     */
    protected $maxColumnWidth = 0;

    /**
     * Whether to wrap the table with borders.
     *
     * @var bool
     */
    protected $isBorderedTable = false;

    /**
     * TableFormatter constructor.
     *
     * @param Colors|null $colors Optional. Instance of the Colors helper object.
     */
    public function __construct(?Colors $colors = null)
    {
        // Try to get terminal width.
        $width = $this->getTerminalWidth();

        if ($width) {
            $this->maxWidth = $width - 1;
        }

        $this->colors = $colors instanceof Colors ? $colors : new Colors();
    }

    /**
     * The currently set border.
     *
     * Defaults to ' '.
     *
     * @return string
     */
    public function getBorder()
    {
        return $this->border;
    }

    /**
     * Set the border.
     *
     * The border is set between each column. Its width is added to the column widths.
     *
     * @param string $border Border to set.
     */
    public function setBorder($border)
    {
        $this->border = $border;
    }

    /**
     * Set the padding.
     *
     * The padding around the border is added to the column widths.
     *
     * @param int $padding Padding to set.
     */
    public function setPadding($padding)
    {
        $this->padding = $padding;
    }

    /**
     * Width of the terminal in characters.
     *
     * Initially auto-detected, with a fallback of 74 characters.
     *
     * @return int
     */
    public function getMaxWidth()
    {
        return $this->maxWidth;
    }

    /**
     * Set the width of the terminal to assume (in characters).
     *
     * @param int $maxWidth Terminal width in characters.
     */
    public function setMaxWidth($maxWidth)
    {
        $this->maxWidth = $maxWidth;
    }

    /**
     * Displays text in multiple word wrapped columns.
     *
     * @param array<int|string> $columns List of column widths (in characters, percent or '*').
     * @param array<string>     $texts   List of texts for each column.
     * @param array<string>     $colors  Optional. A list of color names to use for each column. Use empty string within
     *                                   the array for default. Defaults to an empty array.
     * @return string Adapted text.
     */
    public function format($columns, $texts, $colors = [])
    {
        $columns    = $this->calculateColumnWidths($columns);
        $wrapped    = [];
        $maxLength  = 0;

        foreach ($columns as $column => $width) {
            $wrapped[$column] = explode("\n", $this->wordwrap($texts[$column], $width, "\n", true));
            $length           = count($wrapped[$column]);
            if ($length > $maxLength) {
                $maxLength = $length;
            }
        }

        $last   = count($columns) - 1;
        $output = '';
        for ($index = 0; $index < $maxLength; $index++) {
            foreach ($columns as $column => $width) {
                if ($this->isBorderedTable && $column === 0) {
                    $output .= $this->border . str_repeat(' ', $this->padding);
                    $width = $width - strlen($this->border) - $this->padding;
                }

                if (isset($wrapped[$column][$index])) {
                    $value = $wrapped[$column][$index];
                } else {
                    $value = '';
                }

                if ($this->isBorderedTable && $column === $last && $width > $this->tableColumnWidths[$last]) {
                    $width = $this->tableColumnWidths[$last];
                }

                $chunk = $this->pad($value, $width);

                if (isset($colors[$column]) && $colors[$column]) {
                    $chunk = $this->colors->wrap($chunk, $colors[$column]);
                }
                $output .= $chunk;

                // Add border in-between columns.
                if ($column != $last) {
                    $output .= str_repeat(' ', $this->padding) . $this->border . str_repeat(' ', $this->padding);
                }

                if ($this->isBorderedTable && $column === $last) {
                    $output .= str_repeat(' ', $this->padding) . $this->border;
                }
            }
            $output .= "\n";
        }

        return $output;
    }

    /**
     * Tries to figure out the width of the terminal.
     *
     * @return int Terminal width, 0 if unknown.
     */
    protected function getTerminalWidth()
    {
        // From environment.
        if (isset($_SERVER['COLUMNS'])) {
            return (int)$_SERVER['COLUMNS'];
        }

        // Via tput.
        $process = proc_open(
            'tput cols',
            [
                1 => ['pipe', 'w'],
                2 => ['pipe', 'w'],
            ],
            $pipes
        );

        $width = (int)stream_get_contents($pipes[1]);

        proc_close($process);

        return $width;
    }

    /**
     * Takes an array with dynamic column width and calculates the correct widths.
     *
     * Column width can be given as fixed char widths, percentages and a single * width can be given
     * for taking the remaining available space. When mixing percentages and fixed widths, percentages
     * refer to the remaining space after allocating the fixed width.
     *
     * @param array $columns Columns to calculate the widths for.
     * @return int[] Array of calculated column widths.
     * @throws InvalidColumnFormat If the column format is not valid.
     */
    protected function calculateColumnWidths($columns)
    {
        $index  = 0;
        $border = $this->strlen($this->border);
        $fixed  = (count($columns) - 1) * $border; // Borders are used already.
        $fluid  = -1;

        // First pass for format check and fixed columns.
        foreach ($columns as $index => $column) {
            // Handle fixed columns.
            if ((string)intval($column) === (string)$column) {
                $fixed += $column;
                continue;
            }
            // Check if other columns are using proper units.
            if (substr($column, -1) === '%') {
                continue;
            }
            if ($column === '*') {
                // Only one fluid.
                if ($fluid < 0) {
                    $fluid = $index;
                    continue;
                } else {
                    throw InvalidColumnFormat::forMultipleFluidColumns();
                }
            }
            throw InvalidColumnFormat::forUnknownColumnFormat($column);
        }

        $allocated  = $fixed;
        $remain     = $this->maxWidth - $allocated;

        // Second pass to handle percentages.
        foreach ($columns as $index => $column) {
            if (substr($column, -1) !== '%') {
                continue;
            }
            $percent = floatval($column);

            $real = (int)floor(($percent * $remain) / 100);

            $columns[$index] = $real;
            $allocated       += $real;
        }

        $remain = $this->maxWidth - $allocated;
        if ($remain < 0) {
            throw InvalidColumnFormat::forExceededMaxWidth();
        }

        // Assign remaining space.
        if ($fluid < 0) {
            $columns[$index] += ($remain); // Add to last column.
        } else {
            $columns[$fluid] = $remain;
        }

        return $columns;
    }

    /**
     * Pad the given string to the correct length.
     *
     * @param string $string String to pad.
     * @param int    $length Length to pad the string to.
     * @return string Padded string.
     */
    protected function pad($string, $length)
    {
        $strlen = $this->strlen($string);

        if ($strlen > $length) {
            return $string;
        }

        $pad = $length - $strlen;

        return $string . str_pad('', $pad, ' ');
    }

    /**
     * Measures character length in UTF-8 when possible.
     *
     * @param string $string String to measure the character length of.
     * @return int Count of characters.
     */
    protected function strlen($string)
    {
        // Don't count color codes.
        $string = preg_replace("/\33\\[\\d+(;\\d+)?m/", '', $string);

        if (function_exists('mb_strlen')) {
            return mb_strlen($string, 'utf-8');
        }

        return strlen($string);
    }

    /**
     * Extract a substring in UTF-8 if possible.
     * @param string   $string String to extract a substring out of.
     * @param int      $start  Optional. Starting index to extract from. Defaults to 0.
     * @param int|null $length Optional. Length to extract. Set to null to use the remainder of the string (default).
     * @return string Extracted substring.
     */
    protected function substr($string, $start = 0, $length = null)
    {
        if (function_exists('mb_substr')) {
            return mb_substr($string, $start, $length);
        }

        // mb_substr() treats $length differently than substr().
        if ($length) {
            return substr($string, $start, $length);
        }

        return substr($string, $start);
    }

    /**
     * Wrap words of a string into a requested width.
     *
     * @param string $string String to wrap.
     * @param int    $width  Optional. Width to warp the string into. Defaults to 75.
     * @param string $break  Optional. Character to use for wrapping. Defaults to a newline character. Defaults to the
     *                       newline character.
     * @param bool   $cut    Optional. Whether to cut longer words to enforce the width. Defaults to false.
     * @return string Word-wrapped string.
     * @link http://stackoverflow.com/a/4988494
     */
    protected function wordwrap($string, $width = 75, $break = "\n", $cut = false)
    {
        if (! is_int($width) || $width < 0) {
            $width = 75;
        }

        if (! is_string($break) || empty($break)) {
            $break = "\n";
        }

        $lines = explode($break, $string);
        foreach ($lines as &$line) {
            $line = rtrim($line);
            if ($this->strlen($line) <= $width) {
                continue;
            }
            $words  = explode(' ', $line);
            $line   = '';
            $actual = '';
            foreach ($words as $word) {
                if ($this->strlen($actual . $word) <= $width) {
                    $actual .= $word . ' ';
                } else {
                    if ($actual != '') {
                        $line .= rtrim($actual) . $break;
                    }
                    $actual = $word;
                    if ($cut) {
                        while ($this->strlen($actual) > $width) {
                            $line   .= $this->substr($actual, 0, $width) . $break;
                            $actual = $this->substr($actual, $width);
                        }
                    }
                    $actual .= ' ';
                }
            }
            $line .= trim($actual);
        }

        return implode($break, $lines);
    }

    /**
     * Format the rows in a bordered table.
     *
     * @param array<array<string>> $rows    List of texts for each column.
     * @param array<string>        $headers Optional. List of texts used in the table header.
     *
     * @return string A borered table containing the given rows.
     */
    public function formatTable($rows, $headers = [])
    {
        $this->setBorder('|');
        $this->setPadding(1);
        $this->setIsBorderedTable(true);

        if (! empty($headers)) {
            $this->calculateTableColumnWidths($headers);
        }

        foreach ($rows as $row) {
            $this->calculateTableColumnWidths($row);
        }

        $numberOfColumns  = count($this->tableColumnWidths);

        $columns = array_map(function ($width, $index) {
            // Add extra padding to the first and last columns.
            if ($index === 0 || $index === count($this->tableColumnWidths) - 1) {
                $width = $width + strlen($this->border) + $this->padding;
            }

            return $width;
        }, $this->tableColumnWidths, array_keys($this->tableColumnWidths));

        // For a three column table, we'll have have "| " at start and " |" at the end,
        // and in-between two " | ". So in total "| " + " | " + " | " + " |" = 10 chars.
        $borderCharWidth  = strlen($this->border);
        $totalBorderWidth = 2 * ($borderCharWidth + $this->padding)
            + ($numberOfColumns - 1)
            * ($borderCharWidth + ($this->padding * 2));
        $estimatedColumnWidth = $numberOfColumns * $this->maxColumnWidth;
        $estimatedTotalWidth  = $totalBorderWidth + $estimatedColumnWidth;

        if ($estimatedTotalWidth > $this->maxWidth) {
            $maxWidthWithoutBorders = $this->maxWidth - $totalBorderWidth;

            $avrg          = floor($maxWidthWithoutBorders / $numberOfColumns);
            $resizedWidths = [];
            $extraWidth    = 0;

            foreach ($this->tableColumnWidths as $width) {
                if ($width > $avrg) {
                    $resizedWidths[] = $width;
                } else {
                    $extraWidth = $extraWidth + ($avrg - $width);
                }
            }

            if (! empty($resizedWidths) && $extraWidth) {
                $avrgExtraWidth = floor($extraWidth / count($resizedWidths));

                foreach ($this->tableColumnWidths as $i => &$width) {
                    if (in_array($width, $resizedWidths, true)) {
                        $width = $avrg + $avrgExtraWidth;
                        array_shift($resizedWidths);
                        if (empty($resizedWidths)) {
                            $width = 0; // Zero it so not in sum.
                            $width = $maxWidthWithoutBorders - array_sum($this->tableColumnWidths);
                        }
                    }

                    if ($i === 0 || $i === $numberOfColumns - 1) {
                        $width = $width + strlen($this->border) + $this->padding;
                    }

                    $columns[$i] = intval($width);
                }
            }
        }

        $horizontalBorder = $this->getTableHorizontalBorder($rows[0], $columns);

        $table = $horizontalBorder . "\n";

        if (! empty($headers)) {
            $table .= $this->getTableRow($headers, $columns);
            $table .= $horizontalBorder . "\n";
        }

        foreach ($rows as $row) {
            $table .= $this->getTableRow($row, $columns);
        }

        $table .= $horizontalBorder;

        return $table;
    }

    /**
     * Whether the table is wrapped with borders or not.
     *
     * @param bool $isBorderedTable Whether the table is wrapped with borders or not.
     */
    public function setIsBorderedTable($isBorderedTable)
    {
        $this->isBorderedTable = $isBorderedTable;
    }

    /**
     * Calculate table column widths based on the column content length.
     *
     * @param array<string> $row List of texts for each column.
     */
    protected function calculateTableColumnWidths($row)
    {
        foreach ($row as $i => $rowContent) {
            $width = strlen($rowContent);

            if ($width > $this->maxColumnWidth) {
                $this->maxColumnWidth = $width;
            }

            if (! isset($this->tableColumnWidths[$i]) || $width > $this->tableColumnWidths[$i]) {
                $this->tableColumnWidths[$i] = $width;
            }
        }
    }

    /**
     * Get the table row.
     *
     * @param array<string> $row     List of texts for each column.
     * @param array<int>    $columns List of maximum column widths.
     *
     * @return string Table row.
     */
    protected function getTableRow($row, $columns)
    {
        return trim($this->format($columns, $row)) . "\n";
    }

    /**
     * Get the table horizontal border.
     *
     * @param array<string> $row     List of texts for each column.
     * @param array<int>    $columns List of maximum column widths.
     *
     * @return string Table border.
     */
    protected function getTableHorizontalBorder($row, $columns)
    {
        $tableRow = $this->getTableRow($row, $columns);
        $tableRow = explode("\n", $tableRow);
        $firstRow = array_shift($tableRow);
        $firstRow = trim($firstRow);
        $borderChar = preg_quote($this->border, '/');
        $border     = preg_replace("/[^{$borderChar}]/", '-', $firstRow);
        $border     = preg_replace("/[{$borderChar}]/", '+', $border);

        return $border;
    }
}
