<?php
/*
*     Author: Alex Scott
*      Email: alex@cgi-central.net
*        Web: http://www.amember.com/
*    Release: 6.3.43
*    License: LGPL http://www.gnu.org/copyleft/lesser.html
*/
/**
 * Base grid class (read-only)
 * @package Am_Grid
 */
#[AllowDynamicProperties]
class Am_Grid_ReadOnly
{
    const ACTION_KEY = 'a';
    const ID_KEY = 'id';
    const GROUP_ID_KEY = 'group_id';
    const BACK_KEY = 'b';

    const CB_RENDER_TABLE = 'onRenderTable';
    const CB_RENDER_ROW = 'onRenderRow';
    const CB_RENDER_PAGINATOR = 'onRenderPaginator';
    const CB_RENDER_TITLE = 'onRenderTitle';
    const CB_RENDER_CONTENT = 'onRenderContent';
    const CB_RENDER_STATIC = 'onRenderStatic';
    const CB_TR_ATTRIBS = 'onGetTrAttribs';
    const CB_BEFORE_RUN = 'onBeforeRun';
    const CB_NO_RECORDS = 'onNoRecords';
    const CB_MAKE_URL = 'onMakeUrl';
    const CB_INIT_GRID = 'initGrid';
    const CB_INIT_GRID_FINISHED = 'initGridFinished';

    /** @var Am_Mvc_Response */
    protected $response;
    protected $cssClass = 'am-grid-wrap';
    /** @var string */
    protected $id;
    /** @var string */
    protected $title;
    /** @var array Am_Grid_Field */
    protected $fields = [];
    /** @var Am_Grid_DataSource_Interface_ReadOnly */
    protected $dataSource;
    /** @var Am_Mvc_Request all request as it submitted */
    protected $completeRequest;
    /** @var Am_Mvc_Request only vars specific to this grid */
    protected $request;
    /** @var Am_View passed from controller may be null */
    private $view = null;
    /** @var Am_Grid_Filter_Interface */
    protected $filter;
    /** @var bool set this to not-null to override autodetection */
    protected $isAjax = null;
    /** @var int */
    protected $countPerPage;
    /** @var Am_Di */
    private $di;
    /** @var string by default 'grid'.$this->getId() */
    protected $permissionId = null;
    /** @var array callbackConst => array of callbacks */
    protected $callbacks = [];
    /** @var string eventId */
    protected $eventId = null;

    public function __construct($id, $title,
        Am_Grid_DataSource_Interface_ReadOnly $ds, Am_Mvc_Request $request, Am_View $view, ?Am_Di $di = null)
    {
        if ($id[0] != '_') throw new Am_Exception_InternalError("id must start with underscore _ in " . __METHOD__);
        $this->id = $id;
        $this->title = $title;
        $this->dataSource = $ds;
        $this->view = $view;
        $this->di = $di ?: Am_Di::getInstance();
        $this->countPerPage = $this->getDi()->config->get('admin.records-on-page', 10);
        $this->initGridFields();
        $this->setRequest($request);
        $this->init();
    }

    function init(){}

    public function getId() { return $this->id; }

    /** @return Am_Di */
    public function getDi() { return $this->di; }

    public function setRequest(Am_Mvc_Request $request)
    {
        $this->completeRequest = $request;
        $arr = [];
        foreach ($request->toArray() as $k => $v) {
            if (strpos($k, $this->id.'_') === 0) {
                $k = substr($k, strlen($this->id)+1);
                if (!strlen($k)) continue;
                $arr[$k] = $v;
            }
        }
        $this->request = new Am_Mvc_Request($arr);
    }

    /** must be overriden */
    protected function initGridFields() {}

    function getCountPerPage()
    {
        return $this->countPerPage;
    }

    function setCountPerPage($count)
    {
        $this->countPerPage = (int)$count;
    }

    function getCurrentPage()
    {
        return $this->request->getInt('p');
    }

    /**
     * @template T
     *
     * @param T $field
     * @return (T is object ? T : Am_Grid_Field)
     */
    function addField($field, $title = null, $sortable = null, $align = null, $renderFunc = null, $width = null)
    {
        if (func_num_args()>1 || !$field instanceof Am_Grid_Field) {
            $field = $this->_createField(func_get_args());
        }
        $this->fields[] = $field;
        $field->init($this);
        return $field;
    }

    /**
     * @template T
     *
     * @param T $field
     * @return (T is object ? T : Am_Grid_Field)
     */
    function addFieldBefore($ref, $field, $title = null, $sortable = null, $align = null, $renderFunc = null, $width = null)
    {
        if (func_num_args()>2 || !$field instanceof Am_Grid_Field) {
            //first element is reference
            $field = $this->_createField(array_slice(func_get_args(), 1));
        }

        $ref_name = is_object($ref) ? $ref->getFieldName() : $ref;

        foreach ($this->fields as $k => $f) {
            if ($f->getFieldName() == $ref_name) {
                array_splice($this->fields, $k, 0, [$field]);
                $field->init($this);
                return $field;
            }
        }

        return $this->addField($field);
    }

    /**
     * @template T
     *
     * @param T $field
     * @return (T is object ? T : Am_Grid_Field)
     */
    function addFieldAfter($ref, $field, $title = null, $sortable = null, $align = null, $renderFunc = null, $width = null)
    {
        if (func_num_args()>2 || !$field instanceof Am_Grid_Field) {
            //first element is reference
            $field = $this->_createField(array_slice(func_get_args(), 1));
        }

        $ref_name = is_object($ref) ? $ref->getFieldName() : $ref;

        foreach ($this->fields as $k => $f) {
            if ($f->getFieldName() == $ref_name) {
                array_splice($this->fields, $k+1, 0, [$field]);
                $field->init($this);
                return $field;
            }
        }

        return $this->addField($field);
    }

    /**
     * @template T
     *
     * @param T $field
     * @return (T is object ? T : Am_Grid_Field)
     */
    function prependField($field, $title = null, $sortable = null, $align = null, $renderFunc = null, $width = null)
    {
        if (func_num_args()>1 || !$field instanceof Am_Grid_Field) {
            $field = $this->_createField(func_get_args());
        }
        array_unshift($this->fields, $field);
        $field->init($this);
        return $field;
    }

    /**
     * Find a field by name
     * @throws Am_Exception_InternalError
     * @param string $fieldName
     * @return Am_Grid_Field
     */
    function getField($fieldName, $throwExceptions = true)
    {
        foreach ($this->fields as $field)
            if ($field->getFieldName() == $fieldName) return $field;

        if ($throwExceptions)
            throw new Am_Exception_InternalError("Field [$fieldName] not found in " . __METHOD__);
    }

    /**
     * @deprecated use @link addField instead
     */
    function addGridField($field)
    {
        $args = func_get_args();
        return call_user_func_array([$this, 'addField'], $args);
    }

    function removeField($fieldName)
    {
        foreach ($this->fields as $k => $field) {
            if ($field->getFieldName() == $fieldName) {
                unset($this->fields[$k]);
            }
        }
        $this->fields = array_values($this->fields);
        return $this;
    }

    private function _createField(array $args)
    {
        $reflectionObj = new ReflectionClass('Am_Grid_Field');
        return $reflectionObj->newInstanceArgs($args);
    }

    function setFilter(?Am_Grid_Filter_Interface $filter)
    {
        $this->filter = $filter;
    }

    function getFilter()
    {
        return $this->filter;
    }

    function getFields()
    {
        return $this->fields;
    }

    /** @return Am_Grid_DataSource_Interface_ReadOnly */
    function getDataSource()
    {
        return $this->dataSource;
    }

    function renderTotal($fromCache = true)
    {
        $msgs = [];
        $total = $this->getFoundRows($fromCache);
        $page = $this->getCurrentPage();
        $count = $this->getCountPerPage();

        if ($total === false)
            return "";

        if ($total == 1) {
            $msgs[] = ___("showing %s record", number_format($total));
        } elseif ($total && $total <= $count) {
            $msgs[] = ___("showing %s records", number_format($total));
        } elseif ($total) {
            $msgs[] = ___("showing records %s–%s from %s",
                number_format($page*$count+1),
                number_format(min($total, ($page+1)*$count)),
                number_format($total));
        } else {
            $msgs[] = ___("no records");
        }
        return sprintf("<span class='am-grid-title-desc__total'>%s</span>", implode("", $msgs));
    }

    function renderTitle($noTags = false)
    {
        if ($noTags) return $this->title;
        $ret = '<h1 class="am-grid-title">';
        $ret .= $this->escape($this->title);
        $msgs = [];
        $msgs[] = $this->isAsyncTotals()
            ? $this->renderTotalPlaceholder()
            : $this->renderTotal(false);

        if ($this->filter && $this->filter->isFiltered()) {
            $override = [];
            foreach ($this->filter->getVariablesList() as $k) {
                $override[$k] = null;
            }
            $u = $this->makeUrl($override);
            $u .= (strpos($u, '?') === false) ? '?' : '&';
            $u .= $this->getId();
            $msgs[] = sprintf(
                '%s - <a class="filtered" href="%s">%s</a>',
                ___('filtered'),
                $this->escape($u),
                ___('reset')
            );
        }
        if ($msgs) $ret .= ' <span class="am-grid-title-desc">' . implode(", ", $msgs) . '</span>';
        $ret .= "</h1>";
        // run callback
        $args = [& $ret, $this];
        $this->runCallback(self::CB_RENDER_TITLE, $args);
        // done
        return $ret;
    }

    function getCssClass()
    {
        return $this->cssClass;
    }

    function getTrAttribs($record)
    {
        $ret = [];
        $args = [& $ret, $record];
        $this->runCallback(self::CB_TR_ATTRIBS, $args);
        return $ret;
    }

    function getHiddenVars()
    {
        return [
            'totalRecords' => $this->isAsyncTotals() ? null : $this->getFoundRows(),
            'page' => $this->getRequest()->getInt('p'),
        ];
    }

    /** @return Am_Mvc_Request - with filtered vars */
    function getRequest()
    {
        return $this->request;
    }

    /** @return Am_Mvc_Request - global */
    function getCompleteRequest()
    {
       return $this->completeRequest;
    }

    function renderTable()
    {
        $this->checkPermission(null, 'browse');
        if (empty($this->request))
            throw new Am_Exception_InternalError("request is empty in " . __METHOD__);

        $records = $this->getDataSource()->selectPageRecords($this->getCurrentPage(), $this->getCountPerPage());
        $out = "";
        $args = [&$out, $this];
        if (!$records)
        {
            $this->runCallback(self::CB_NO_RECORDS, $args);
            if ($out != '') //something has been received from the callback
                return $out;
        }

        $attr = $this->isAsyncTotals() ? [
            'data-storage' => 'info',
            'data-url' => $this->makeUrl([self::ACTION_KEY => 'reload']),
            'data-key' => 'totalRecords',
        ] : [];

        $out .= '<div class="am-grid-container">'.PHP_EOL;
        $out .= sprintf(
            '<table class="am-grid%s" data-info="%s" %s>'.PHP_EOL,
            ($this->isAsyncTotals() ? ' need-reload' : ''),
            $this->escape(json_encode($this->getHiddenVars())),
            Am_Html::attrs($attr)
        );
        $out .= "\t<thead><tr>\n";
        foreach ($this->getFields() as $field)
            $out .= "\t\t" . $field->renderTitle($this) . PHP_EOL;
        $out .= "\t</tr></thead><tbody>\n";
        foreach ($records as $record) {
            $row = $this->renderRow($record);
            $args = [& $row, $record, $records, $this];
            $this->runCallback(self::CB_RENDER_ROW, $args);
            $out .= $row;
        }
        $out .= "</tbody></table></div>\n\n";
        // run callback
        $args = [& $out, $this];
        $this->runCallback(self::CB_RENDER_TABLE, $args);
        // done
        return $out;
    }

    function renderRow($record)
    {
        $out = "";

        $attribs = (array)$this->getTrAttribs($record);
        if (isset($attribs['class'])) {
            $attribs['class'] .= ' am-grid-row';
        } else {
            $attribs['class'] = 'am-grid-row';
        }
        static $odd = 0; // to get zebra colors
        if (empty($attribs['class'])) $attribs['class'] = "";
        if ($odd++ % 2) {
            $attribs['class'] = "odd " . $attribs['class'];
        } else {
            $attribs['class'] = "even " . $attribs['class'];
        }
        $astring = "";
        foreach ($attribs as $k => $v)
            $astring .= ' ' . htmlentities($k, 0, 'UTF-8') . '="' . htmlentities($v, ENT_QUOTES, 'UTF-8').'"';

        $out .= "\t<tr".$astring.">\n";
        foreach ($this->getFields() as $field)
            $out .= "\t\t" . $field->render($record, $this) . "\n";
        $out .= "\t</tr>\n";
        return $out;
    }

    protected function renderTotalPlaceholder()
    {
        return sprintf(
            "<span class='need-reload' data-url='%s' data-key='total'>%s</span>",
            $this->makeUrl([self::ACTION_KEY => 'reload']),
            ''
        );
    }

    protected function renderPaginatorPlaceholder()
    {
        return sprintf("<span class='need-reload' data-url='%s' data-key='paginator'>%s</span>",
            $this->makeUrl([self::ACTION_KEY => 'reload']),
            ''
        );
    }

    function renderPaginator($fromCache = true, $urlTemplate = null)
    {
        $total = $this->getFoundRows($fromCache);
        $p = new Am_Paginator(ceil($total/$this->getCountPerPage()), $this->getCurrentPage(), $urlTemplate, $this->id . '_p', $this->getCompleteRequest());
        $out = $p->render();
        $args = [&$out, $this];
        $this->runCallback(self::CB_RENDER_PAGINATOR, $args);
        return $fromCache ? sprintf("<span class='need-reload' data-url='%s' data-key='paginator'>%s</span>", $this->makeUrl([
            self::ACTION_KEY => 'reload',

        ]), $out) : $out;
    }

    function renderFilter()
    {
        if ($this->filter)
            return $this->filter->renderFilter();
    }

    function renderContent()
    {
        // it is important to run it first to get query executed
        $table = $this->renderTable();
        $out =
            $this->renderFilter() .
            $table .
            ($this->isAsyncTotals() ? $this->renderPaginatorPlaceholder() : $this->renderPaginator($this->canCacheTotals()));

        // run callback
        $args = [& $out, $this];
        $this->runCallback(self::CB_RENDER_CONTENT, $args);
        // done
        return $this->renderTitle() . $out;
    }

    /**
     * Render static html or js or css code that must not be reloaded
     * during AJAX requests
     */
    function renderStatic()
    {
        $out = "";
        foreach ($this->fields as $field)
            $out .= $field->renderStatic() . PHP_EOL;
        if ($this->filter)
            $out .= $this->filter->renderStatic() . PHP_EOL;
        // run callback
        $args = [& $out, $this];
        $this->runCallback(self::CB_RENDER_STATIC, $args);
        return $out;
    }

    function render()
    {
        return sprintf(
            '<!-- start of grid -->' . PHP_EOL .
            '<div class="%s" id="%s">'. PHP_EOL .
            '%s' . PHP_EOL .
            "</div>" . PHP_EOL .
            '%s' . PHP_EOL .
            '<!-- end of grid -->'
            ,
            $this->getCssClass(),
            'grid-' . preg_replace('/^_/', '', $this->getId()),
            $this->renderContent(),
            $this->renderStatic()
        );
    }

    /** @array string html */
    function renderGridHeaderSortHtml(Am_Grid_Field $field)
    {
        $desc = null;
        @[$sort, $desc] = explode(' ', $this->request->getParam('sort'), 2);
        if ($sort == $field->getFieldName())
            $desc = ($desc != "DESC");
        $url = $this->escape($this->makeUrl([
            'sort' => $field->getFieldName() . ($desc ? " DESC" : ""),
        ]));

        $cssClass = "a-sort";
        if ($sort == $field->getFieldName())
        {
            $cssClass .= $desc ? ' sorted-desc' : ' sorted-asc';
        }
        $sort1 = sprintf("<a class='$cssClass' href='%s'><span>", $url);
        $sort2 = "</span></a>";
        return [$sort1, $sort2];
    }

    static function renderTd($content, $doEscape = true, $attr = [])
    {
        return sprintf('<td%s>%s</td>',
            $attr ? ' ' . Am_Html::attrs($attr) : '',
            $doEscape ? self::escape($content) : $content);
    }

    /**
     * if $override === null return url without ANY parameters
     */
    public function makeUrl($override = [], $includeGlobal = true)
    {
        if ($includeGlobal) {
            $req = $this->completeRequest->toArray();
        } else {
            $req = null;
        }
        $uri = $this->completeRequest->getRequestUri();
        $uri = preg_replace('/\?.*/', '', $uri);
        if (defined('ROOT_URL') && defined('ROOT_SURL'))
            $uri = str_replace([ROOT_URL, ROOT_SURL], [REL_ROOT_URL, REL_ROOT_URL], $uri);

        if ($override === null) {
            $req = [];
        } else {
            foreach ($override as $x => $y) {
                $x = $this->id . '_' . $x;
                if ($y === null)
                    unset($req[$x]);
                else
                    $req[$x] = $y;
            }
        }
        $args = [$uri, $req];
        $this->runCallback(self::CB_MAKE_URL, $args);
        [$uri, $req] = $args;
        return $req ? ($uri . '?' . http_build_query($req, '', '&')) : $uri;
    }

    public function run(?Am_Mvc_Response $response = null)
    {
        $args = [$this];
        $this->runCallback(self::CB_INIT_GRID, $args);
        $this->runCallback(self::CB_INIT_GRID_FINISHED, $args);

        $sort = $this->request->get('sort');
        if (!empty($sort)) {
            $sort = explode(' ', $sort, 2);
            $this->getDataSource()->setOrder(filterId($sort[0]), !empty($sort[1]));
        }

        if ($this->filter) {
            $this->filter->initFilter($this);
        }

        $args = [$this];
        $this->runCallback(self::CB_BEFORE_RUN, $args);

        $response ??= new Am_Mvc_Response;
        $this->response = $response;
        $action = $this->getCurrentAction();
        $this->request->setActionName($action);

        ob_start();
        $this->actionRun($action);

        if ($this->response->isRedirect() && $this->completeRequest->isXmlHttpRequest()) {
            $url = null;
            foreach ($response->getHeaders() as $header)
                if ($header['name'] == 'Location') $url = $header['value'];
            $code = $response->getHttpResponseCode();
            // change request to ajax response
            $response->clearAllHeaders();
            $response->clearBody();
            $response->setHttpResponseCode(200);
            $response->setHeader("Content-Type","application/json; charset=UTF-8", true);
            $response->setBody(json_encode(['ngrid-redirect' => $url, 'status' => $code]));
            //throw new Am_Exception_Redirect($url);
        } else {
            $response->appendBody(ob_get_clean());
        }
        unset($this->response);
        return $response;
    }

    /** @return string */
    public function getCurrentAction()
    {
        return $this->request->getFiltered(self::ACTION_KEY, 'index');
    }

    public function actionRun($action)
    {
        $callback = [$this, $action.'Action'];
        if (!is_callable($callback))
            throw new Am_Exception_InternalError("Action [$action] does not exists in " . get_class($this));
        call_user_func($callback);
    }

    public function runWithLayout($layout = 'admin/layout.phtml', ?Am_Mvc_Response $response = null)
    {
        if (!$response)
            $response = new Am_Mvc_Response;
        $this->run($response);
        if ($this->completeRequest->isXmlHttpRequest() || $response->isRedirect()) {
            $response->sendResponse();
        } else {
            $view = $this->getView();
            $view->layoutNoTitle = true;
            $view->title = $this->renderTitle(true);
            $view->content = $response->getBody();
            $view->display($layout);
        }
    }

    public function indexAction()
    {
        echo $this->isAjax() ? $this->renderContent() : $this->render();
    }

    function reloadAction()
    {
        $url = $this->makeUrl([self::ACTION_KEY => 'index', 'p'=>null]);
        $insert = urlencode($this->id.'_p').'=###PAGE###' ;
        $url .= (strpos($url, '?') !== false) ? ('&' . $insert) : ('?' . $insert);
        $this->getDi()->session->writeClose();
        $this->getDi()->response->ajaxResponse([
            'totalRecords' => $this->getFoundRows(false),
            'total' => $this->renderTotal(false),
            'paginator' => $this->renderPaginator(false, $url)
        ]);
    }

    public function isAjax($setFlag = null)
    {
        if ($setFlag !== null)
            $this->isAjax = (bool)$setFlag;
        if ($this->isAjax !== null)
            return $this->isAjax;
        return $this->completeRequest->isXmlHttpRequest();
    }

    /**
     * @return array string of variable names to pass between requests
     */
    public function getVariablesList()
    {
        $ret = $this->filter ? $this->filter->getVariablesList() : [];
        $ret[] = self::ACTION_KEY;
        return $ret;
    }

    function addCallback($gridEvent, $callback)
    {
        $this->callbacks[$gridEvent][] = $callback;
    }

    function getCallbacks()
    {
        return $this->callbacks;
    }

    function runCallback($gridEvent, array &$args)
    {
        if (!empty($this->callbacks[$gridEvent])) {
            foreach ($this->callbacks[$gridEvent] as $callback) {
                call_user_func_array($callback, $args);
            }
        }
        // now run external callbacks
        $event = new Am_Event_Grid($gridEvent, $args, $this);
        $this->getDi()->hook->call('grid' . ucfirst($gridEvent), $event);
        $this->getDi()->hook->call($this->eventId . ucfirst($gridEvent), $event);
        $args = $event->getArgs();
    }

    /**
     * If set, the grid will raise Am_Event_Grid for all callbacks (see constants)
     * @param string $eventId
     */
    function setEventId($eventId)
    {
        $this->eventId = (string)$eventId;
    }

    static function escape($s)
    {
        return htmlentities($s, ENT_QUOTES, 'UTF-8');
    }

    function getView()
    {
        return $this->view ?: $this->getDi()->view;
    }

    function hasPermission($perm = null, $priv = null)
    {
        if (!defined('AM_ADMIN') || !AM_ADMIN)
            return true;
        if ($perm === null)
            $perm = $this->getPermissionId();
        return $this->getDi()->authAdmin->getUser()->hasPermission($perm, $priv);
    }

    function checkPermission($perm = null, $priv = null)
    {
        if ($perm === null)
            $perm = $this->getPermissionId();
        if (!$this->hasPermission($perm, $priv))
            $this->throwPermission($perm, $priv);
    }

    function getPermissionId()
    {
        return $this->permissionId ?: 'grid' . $this->id;
    }

    function setPermissionId($id)
    {
        $this->permissionId = $id;
    }

    function throwPermission($perm = null, $priv = null)
    {
        if ($perm === null)
            $perm = $this->getPermissionId();
        throw new Am_Exception_AccessDenied(___("You have no enough permissions for this operation")
            ." (".$perm."-$priv)");
    }

    function canCacheTotals()
    {
        return AM_HUGE_DB && !AM_HUGE_DB_NO_CACHE && method_exists($this->getDataSource(), 'getCacheId');
    }

    function isAsyncTotals()
    {
        return AM_HUGE_DB;
    }

    /**
     * Get found rows from cache, if not exists get it from datasource and cache it;
     */
    function getFoundRows($fromCache = true)
    {
        if (
            $this->canCacheTotals()
            && $fromCache
            && ($_ = $this->getDi()->cache->load($this->getDataSource()->getCacheId('long-time')))
        ) {
            return $_;
        }

        $foundRows = $this->dataSource->getFoundRows();

        if ($this->canCacheTotals()) {
            $this->getDi()->cache->save($foundRows, $this->getDataSource()->getCacheId('long-time'), [AM_HUGE_DB_CACHE_TAG], AM_HUGE_DB_CACHE_TIMEOUT * 24 * 10);
        }

        return $foundRows;
    }
}