<?php
namespace nur\v\bs3\vc;

use Exception;
use nur\A;
use nur\b\params\IParametrable;
use nur\b\params\Tparametrable1;
use nur\data\types\md_utils;
use nur\data\types\Metadata;
use nur\func;
use nur\iter;
use nur\SL;
use nur\v\base\ComponentPrintable;
use nur\v\v;
use nur\v\vo;

/**
 * Class CTable: affiche un tableau de données
 */
class CTable extends ComponentPrintable implements IParametrable {
  use Tparametrable1;

  const COLS = null;
  const EXCLUDE_COLS = null;
  const ADD_COLS = null;
  const HEADERS = null;
  const ADD_HEADERS = null;
  /** @var array schéma des données à afficher */
  const SCHEMA = null;
  const TABLE_CLASS = "table-bordered";
  const AUTOPRINT = null;
  /** @var ?array schéma des informations supplémentaires */
  const DATA_SCHEMA = null;

  /**
   * retourner la valeur de COLS construite dynamiquement. cette méthode n'est
   * appelée que si c'est nécessaire
   */
  protected function COLS(): ?array {
    return null;
  }
  /**
   * retourner la valeur de HEADERS construite dynamiquement. cette méthode
   * n'est appelée que si c'est nécessaire
   */
  protected function HEADERS(): ?array {
    return null;
  }

  function __construct(?iterable $rows=null, ?array $params=null) {
    self::set_parametrable_params_defaults($params, [
      "cols" => static::COLS,
      "exclude_cols" => static::EXCLUDE_COLS,
      "add_cols" => static::ADD_COLS,
      "headers" => static::HEADERS,
      "add_headers" => static::ADD_HEADERS,
      "schema" => static::SCHEMA,
      "table_class" => static::TABLE_CLASS,
      "autoprint" => static::AUTOPRINT,
      "data_schema" => static::DATA_SCHEMA,
    ]);
    if ($rows === null) $rows = $this->defaultRows();
    A::set_nn($params, "rows", $rows);
    [$params, $data] = $this->splitParametrableParams($params);
    if ($data) A::merge($params["data"], $data);
    $this->initParametrableParams($params);
    if ($this->ppAutoprint) $this->print();
  }

  protected function defaultRows(): ?iterable {
    return null;
  }

  const PARAMETRABLE_PARAMS_SCHEMA = [
    "rows" => ["?iterable", null, "source des lignes à afficher"],
    "filter_func" => ["?callable", null, "fonction permettant de filter les éléments à afficher"],
    "map_func" => ["?callable", null, "fonction permettant de mapper les éléments"],
    "map" => ["?array", null, "tableau permettant de sélectionner les éléments"],
    "cols" => ["?array", null, "colonnes à extraire et à afficher"],
    "exclude_cols" => ["?array", null, "colonnes à exclure de la liste calculée automatiquement"],
    "add_cols" => ["?array", null, "colonnes à ajouter à la liste calculée automatiquement"],
    "headers" => ["?array", null, "en-têtes correspondant aux colonnes"],
    "add_headers" => ["?array", null, "en-têtes à ajouter à la liste calculée automatiquement"],
    "schema" => ["?array", null, "schéma des données à afficher"],
    "table_class" => ["?array", null, "classe CSS du tableau"],
    "table_attrs" => ["?array", null, "autres attributs à placer sur le tableau"],
    "before_table" => ["?content", null, "Contenu à afficher avant le tableau s'il y a des lignes à afficher"],
    "row_func" => ["?callable", null, "fonction avec la signature (\$vs, \$row, \$rawRow) retournant la ligne à afficher"],
    "col_func" => ["?callable", null, "fonction avec la signature (\$vs, \$value, \$col, \$index, \$row, \$rawRow) retournant la colonne à afficher"],
    "after_table" => ["?content", null, "Contenu à afficher après le tableau s'il y a des lignes à afficher"],
    "no_data" => ["?content", null, "Contenu à afficher s'il n'y a pas de lignes à afficher"],
    "on_exception" => ["?callable", null, "fonction à appeler en cas d'exception"],
    "autoprint" => ["bool", false, "faut-il afficher automatiquement le tableau"],
    "data" => ["?array", null, "donnés supplémentaires utilisées pour l'affichage"],
    "data_schema" => ["?array", null, "schéma des données supplémentaire"],
  ];


  const COL_SCHEMA = [
    "name" => ["?string", null, "Nom de la colonne"],
    "pkey" => ["?string", null, "Clé permettant de calculer la valeur effective de la colonne"],
  ];
  private static $col_md;

  const HEADER_SCHEMA = [
    "name" => ["string", null, "Nom de l'en-tête"],
    "span" => ["?int", null, "nombre de colonnes que cet en-tête doit occuper"],
    "label" => ["?string", null, "Libellé de l'en-tête",
      "desc" => "vaut [name] par défaut",
    ],
  ];
  private static $header_md;

  /** @var Metadata schéma des données */
  protected $md;

  function pp_setSchema($schema): void {
    $this->md = Metadata::with($schema);
  }

  /** @var ?iterable */
  protected $ppRows;

  /** @var array */
  protected $filterCtx;

  function pp_setFilterFunc($filterFunc): void {
    if ($filterFunc === null) $this->filterCtx = null;
    else $this->filterCtx = func::_prepare($filterFunc);
  }

  /** @var array */
  protected $mapCtx;

  function pp_setMapFunc($mapFunc): void {
    if ($mapFunc === null) $this->mapCtx = null;
    else $this->mapCtx = func::_prepare($mapFunc);
  }

  /** @var ?array */
  protected $ppMap;

  protected $ppCols;

  protected $ppExcludeCols;

  protected $ppAddCols;

  protected $ppHeaders;

  protected $ppAddHeaders;

  /** @var array classe CSS du tableau */
  protected $ppTableClass;

  protected function getTableClass(): array {
    return $this->ppTableClass;
  }

  /** @var array */
  protected $ppTableAttrs;

  /** @var array|string */
  protected $ppBeforeTable;

  /** @var array */
  protected $rowCtx;

  function pp_setRowFunc($rowFunc): void {
    if ($rowFunc === null) $this->rowCtx = null;
    else $this->rowCtx = func::_prepare($rowFunc);
  }

  /** @var array */
  protected $colCtx;

  function pp_setColFunc($colFunc): void {
    if ($colFunc === null) $this->colCtx = null;
    else $this->colCtx = func::_prepare($colFunc);
  }

  /** @var array|string */
  protected $ppAfterTable;

  /** @var array|string */
  protected $ppNoData;

  /** @var callable */
  protected $ppOnException;

  /** @var bool */
  protected $ppAutoprint;

  /** @var Metadata */
  protected $dataSchema;

  function pp_setDataSchema($dataSchema): void {
    $this->dataSchema = Metadata::with($dataSchema);
  }

  /** @var mixed données supplémentaires utilisées pour l'affichage */
  protected $data;

  function pp_setData(?array $data): void {
    $this->data = $data;
  }

  protected function afterSetParametrableParams(array $modifiedKeys, ?Metadata $md=null): void {
    if (self::was_parametrable_param_modified($modifiedKeys
      , "cols", "exclude_cols", "add_cols"
      , "headers", "add_headers")) {
      $this->cols = null;
      $this->headers = null;
    }
    if (self::was_parametrable_param_modified($modifiedKeys, "data", "data_schema")) {
      if ($this->dataSchema !== null) {
        $this->dataSchema->ensureSchema($this->data);
      }
    }
  }

  /** @var array liste des colonnes à extraire des lignes */
  protected $cols;

  /** @var array informations sur les colonnes à extraire des lignes */
  protected $colInfos;

  /** @var array en-têtes correspondant aux colonnes extraites */
  protected $headers;

  protected function updateColInfos(?array &$cols, ?array &$colInfos): void {
    # extraire les données de colonnes pour que $cols soit juste une liste de
    # colonnes
    if ($cols === null) return;
    if ($colInfos === null) $colInfos = [];
    $colKeys = [];
    $index = 0;
    foreach ($cols as $key => $col) {
      if ($key === $index) {
        $index++;
        if ($col === null) {
          # colonne "nulle", à prendre telle quelle
          $colKeys[] = null;
        } elseif (!array_key_exists($col, $colInfos)) {
          $colKeys[] = $col;
          $colInfos[$col] = null;
        }
      } else {
        if (is_array($col) && array_key_exists("name", $col)) {
          $key = $col["name"];
        }
        if (!array_key_exists($key, $colInfos)) $colKeys[] = $key;
        $colInfos[$key] = $col;
      }
    }
    $cols = $colKeys;
  }

  protected function resolveColsHeaders($row): bool {
    if ($this->cols !== null || $this->headers !== null) return false;
    $schema = $this->md;

    $cols = $this->ppCols;
    $colsExcludes = $this->ppExcludeCols;
    if ($cols === null) $cols = $this->COLS();
    if ($cols === null && $schema !== null) {
      $cols = A::xselect($schema->getKeys(), null, $colsExcludes);
    }
    $this->updateColInfos($cols, $colInfos);

    $headers = $this->ppHeaders;
    $sfields = null;
    if ($headers === null) $headers = $this->HEADERS();
    if ($headers === null) {
      if ($schema !== null) {
        # ici, $cols est forcément défini, à cause de la commande ci-dessus
        $headers = [];
        $sfields = $schema->getSfields();
        foreach ($cols as $col) {
          $sfield = A::get($sfields, $col);
          $headers[] = $sfield !== null? $sfield["header"]: $col;
        }
      } elseif ($cols !== null) {
        $headers = $cols;
      }
    }

    $skipRow = false;
    if ($cols === null || $headers === null) {
      # si pas de colonnes ou de headers, prendre la première ligne si
      # c'est une séquence ou ses clés si c'est un tableau associatif
      if (A::is_seq($row)) {
        $skipRow = true;
        $keys = A::xselect($row, null, $colsExcludes);
        if ($headers === null) $headers = $keys;
        if ($cols === null) $cols = array_keys($keys);
      } else {
        $row = A::xselect_keys($row, null, $colsExcludes);
        if ($headers === null) $headers = array_keys($row);
        if ($cols === null) $cols = array_keys($row);
      }
    }

    # ces cas ne devraient pas se produire puisqu'ils devraient être récupérés
    # par la section no_contents, mais bon... au cas où
    if ($headers === null) $headers = [];
    if ($cols === null) $cols = [];

    $addCols = $this->ppAddCols;
    $this->updateColInfos($addCols, $colInfos);
    $addHeaders = $this->ppAddHeaders;
    if ($addCols !== null && $addHeaders !== null) {
      # si on fournit les deux, les intégrer sans modification
      A::merge($cols, $addCols);
      A::merge($headers, $addHeaders);
    } elseif ($addCols !== null) {
      # si on ne donne que des colonnes supplémentaires, inférer les en-têtes
      # à rajouter
      foreach ($addCols as $col) {
        $cols[] = $col;
        if ($col === null) {
          $headers[] = "";
        } elseif ($sfields !== null) {
          $sfield = A::get($sfields, $col);
          $headers[] = $sfield !== null? $sfield["header"]: $col;
        } else {
          $headers[] = $col;
        }
      }
    } elseif ($addHeaders !== null) {
      # si on ne donne que des en-têtes supplémentaires, rajouter des colonnes
      # nulles pour compenser
      foreach ($addHeaders as $header) {
        $cols[] = null;
        $headers[] = $header;
      }
    }

    md_utils::ensure_md(self::$col_md, self::COL_SCHEMA)
      ->eachEnsureSchema($colInfos);
    md_utils::ensure_md(self::$header_md, self::HEADER_SCHEMA)
      ->eachEnsureSchema($headers);

    $this->cols = $cols;
    $this->colInfos = $colInfos;
    $this->headers = $headers;
    return $skipRow;
  }

  protected function _rewindRows(): bool {
    return iter::rewind($this->ppRows);
  }

  protected function _validRow(): bool {
    return iter::valid($this->ppRows);
  }

  protected function _currentRow(&$key=null) {
    return iter::current($this->ppRows, $key);
  }

  protected function _nextRow(): void {
    iter::next($this->ppRows);
  }

  protected function nextRow(): ?array {
    $filterCtx = $this->filterCtx;
    $mapCtx = $this->mapCtx;
    $map = $this->ppMap;
    $row = $this->rawRow;
    if ($filterCtx !== null) {
      if (!func::_call($filterCtx, [$row, $this->rowKey, $this->rowIndex])) return null;
    }
    if ($mapCtx !== null) {
      $row = func::_call($mapCtx, [$row, $this->rowKey, $this->rowIndex]);
    } elseif ($map !== null) {
      # si la valeur est séquentielle, c'est une clé dans $row
      # si la valeur est associative et que c'est une fonction, elle est appelée
      # avec la valeur
      $row = A::with($row);
      $index = 0;
      $mapped = [];
      foreach ($map as $key => $value) {
        if ($key === $index) {
          $index++;
          if ($value === null) $mapped[] = null;
          else $mapped[$value] = A::get($row, $value);
        } elseif (is_callable($value)) {
          $mapped[$key] = func::call($value, A::get($row, $key), $key, $row, $this->rowKey, $this->rowIndex);
        } else {
          $mapped[$key] = $value;
        }
      }
      $row = $mapped;
    } else {
      $row = A::with($row);
    }
    return $row;
  }

  protected function getRowValues(array $row): array {
    $havePkey = false;
    if ($this->colInfos !== null) {
      foreach ($this->colInfos as $colInfo) {
        if ($colInfo["pkey"] !== null) {
          $havePkey = true;
          break;
        }
      }
    }
    if (!$havePkey) return $row;
    $cooked = [];
    foreach ($this->cols as $col) {
      if ($col === null) {
        $cooked[] = null;
      } else {
        $colInfo = A::get($this->colInfos, $col);
        $pkey = $colInfo !== null? $colInfo["pkey"]: null;
        if ($pkey !== null) $value = A::pget($row, $pkey);
        else $value = $row[$col];
        $cooked[$col] = $value;
      }
    }
    # compléter avec les autres champs tels quels
    foreach ($row as $key => $value) {
      if (!array_key_exists($key, $cooked)) {
        $cooked[$key] = $value;
      }
    }
    return $cooked;
  }

  protected $results;

  protected function ensureRowSchema(array &$row): void {
    $md = $this->md;
    if ($md !== null) {
      $md->ensureSchema($row);
      $md->verifix($row, $this->results, false, true);
      #XXX il faut garder les champs "autres"!
      #$row = $md->format($row);
    }
  }

  protected function cookRow(array $row): array {
    $cooked = $this->getRowValues($row);
    $this->ensureRowSchema($cooked);
    return $cooked;
  }

  function print(): void {
    try {
      $this->_rewindRows();
      $this->rowIndex = 0;
      $haveRows = false;
      while ($this->_validRow()) {
        $this->rawRow = $this->_currentRow($this->rowKey);
        try {
          $this->origRow = $this->nextRow();
          if ($this->origRow === null) continue;

          $skipRow = false;
          if (!$haveRows) $skipRow = $this->resolveColsHeaders($this->origRow);
          if (!$skipRow) {
            $this->row = $this->cookRow($this->origRow);
            if (!$haveRows) {
              $haveRows = true;
              vo::print($this->beforeTable());
              vo::stable(["class" => "table", $this->table()]);

              vo::sthead($this->thead());
              $this->printBeforeHeader();
              $this->printHeader($this->headers);
              $this->printAfterHeader();
              vo::ethead();

              vo::stbody($this->tbody());
            }
            $this->printBeforeRow();
            $this->printRow($this->row);
            $this->printAfterRow();
            $this->rowIndex++;
          }
        } finally {
          $this->_nextRow();
        }
      }
      if ($haveRows) {
        vo::etbody();
        vo::etable();
        vo::print($this->afterTable());
      } else {
        vo::print($this->noData());
      }
    } catch (Exception $e) {
      $onException = $this->ppOnException;
      if ($onException !== null) func::call($onException, $e);
      else throw $e;
    }
  }

  /** contenu à afficher avant le tableau s'il y a des lignes à afficher */
  function beforeTable(): ?iterable {
    return $this->ppBeforeTable;
  }

  /** contenu à afficher après le tableau s'il y a des lignes à afficher */
  function afterTable(): ?iterable {
    return $this->ppAfterTable;
  }

  /** contenu à afficher s'il n'y a pas de lignes à afficher */
  function noData(): ?iterable {
    return $this->ppNoData;
  }

  function table(): ?iterable {
    return SL::merge(["class" => $this->getTableClass()], $this->ppTableAttrs);
  }

  function thead(): ?iterable {
    return null;
  }

  /** contenu à afficher avant la ligne */
  function beforeHeader(): ?iterable {
    return null;
  }

  function printBeforeHeader(): void {
    $beforeHeaderContents = $this->beforeHeader();
    if ($beforeHeaderContents !== null) vo::tr($beforeHeaderContents);
  }

  /** contenu à afficher après la ligne */
  function afterHeader(): ?iterable {
    return null;
  }

  function printAfterHeader(): void {
    $afterHeaderContents = $this->afterHeader();
    if ($afterHeaderContents !== null) vo::tr($afterHeaderContents);
  }

  function printHeader(array $headers): void {
    vo::print($this->headerTr($headers));
  }

  function headerTr(array $headers): array {
    return v::tr($this->headerRow($headers));
  }

  function headerRow(array $headers): ?iterable {
    $vs = [];
    foreach ($headers as $header) {
      $label = $header["label"];
      if ($label === null) $label = $header["name"];
      $colspan = $header["span"];
      $vs[] = v::th(["colspan" => $colspan, $label]);
    }
    return $vs;
  }

  function tbody(): ?iterable {
    return null;
  }

  /** @var int index de la ligne courante */
  protected $rowIndex;

  /** @var string|int clé de la ligne courante */
  protected $rowKey;

  /** @var array la ligne originale, avant le mapping */
  protected $rawRow;

  /** @var array la ligne mappée avant sa "cuisine" */
  protected $origRow;

  /** @var array la ligne cuisinée */
  protected $row;

  /** contenu à afficher avant la ligne */
  function beforeRow(): ?iterable {
    return null;
  }

  function printBeforeRow(): void {
    $beforeRowContents = $this->beforeRow();
    if ($beforeRowContents !== null) vo::tr($beforeRowContents);
  }

  /** contenu à afficher après la ligne */
  function afterRow(): ?iterable {
    return null;
  }

  function printAfterRow(): void {
    $afterRowContents = $this->afterRow();
    if ($afterRowContents !== null) vo::tr($afterRowContents);
  }

  function printRow(array $row): void {
    vo::print($this->rowTr($row));
  }

  function rowTr(array $row): array {
    $vs = $this->row($row);
    if ($this->rowCtx !== null) {
      $vs = func::_call($this->rowCtx, [$vs, $row, $this->rawRow]);
    }
    return v::tr($vs);
  }

  function row(array $row): ?iterable {
    $vs = [];
    $this->index = 0;
    foreach ($this->cols as $this->col) {
      if ($this->col === null) $value = null;
      else $value = $row[$this->col];
      $vs[] = $this->colTd($value);
      $this->index++;
    }
    return $vs;
  }

  /** @var int index de la colonne courante */
  protected $index;

  /** @var string|int clé de la colonne courante */
  protected $col;

  function colTd($value): ?array {
    $vs = $this->col($value);
    if ($this->colCtx !== null) {
      $vs = func::_call($this->colCtx, [$vs, $value, $this->col, $this->index, $this->row, $this->rawRow]);
      if ($vs === false) return null;
    } else {
      $result = A::get($this->results, $this->col);
      $valid = $result === null || $result["valid"];
      $vs = [
        "class" => ["danger" => !$valid],
        $vs,
      ];
    }
    return v::td($vs);
  }

  function col($value): ?iterable {
    #XXX formatter selon le schéma, ou en utilisant des méthodes définies dans
    # cette classe
    return q($value);
  }
}
