<?php
namespace nur\m\pdo\mysql;

use Exception;
use nur\A;
use nur\b\ValueException;
use nur\base;
use nur\func;
use nur\m\base\AbstractRow;
use nur\md;
use nur\php\SrcGenerator;
use nur\str;
use ReflectionClass;

/**
 * Class MysqlMigrations
 *
 * Dans la classe destination (celle à partir de laquelle sont générées les
 * migrations), les constantes suivantes peuvent être définies:
 * - DBTRAIT: classe qui est utilisée comme dbtrait pour *toutes* les migrations
 * - DEFAULT_DBTRAIT: classe qui est utilisée comme dbtrait pour les migrations
 *   qui n'en définissent pas
 * - MERGE: une classe XxxMigration qu'il faut aussi considérer
 * - MERGES: une liste de classes XxxMigration qu'il faut aussi considérer.
 *
 * Les constantes MERGE et MERGES sont calculées récursivement. Les constantes
 * DBTRAIT et DEFAULT_DBTRAIT ne sont consultées que dans la classe destination.
 */
class MysqlMigrations {
  const UPDATE_DIRECTIVES = [
    "+name", # nom de base du fichier sql généré
    "+desc", # description à insérer dans le fichier sql
    "+dbtrait", # trait qui fourni verifix_conn(). Si cette valeur n'est pas fournie, la constante DBTRAIT de la classe destination donne la valeur par défaut
    "+prod", # cette configuration a-t-elle été déployée en prod? (ne plus modifier)
  ];
  const TABLE_DIRECTIVES = [
    "+name", # nom effectif de la table
    "+desc", # description à insérer en commentaire
    "+type", # type: table (par défaut) ou view
    # directives spécifiques aux tables
    "+suffix", # suffixe des colonnes de cette table
    "+primary key",
    "+foreign key",
    "+index",
    "+unique index",
    # directives spécifiques aux vues
    "+view_tables", # tables participant aux vues
    "+view_def", # définition de la vue
  ];

  ###

  private static $trace;
  private static function trace($msg) {
    if (self::$trace !== null) func::call(self::$trace, $msg);
  }

  /**
   * tester si le fichier doit être écrasé
   *
   * si $overwrite n'est pas booléen, ce doit être un nombre maximum de ligne au
   * delà duquel le fichier n'est pas écrasé
   */
  private static function should_overwrite($file, $overwrite) {
    if (!is_int($overwrite)) $overwrite = boolval($overwrite);
    if ($overwrite === true || $overwrite === false) return $overwrite;
    if (!is_file($file)) return true;
    $inf = fopen($file, "rb");
    try {
      while ($overwrite > 0) {
        $line = fgets($inf);
        if ($line === false) return true;
        $overwrite--;
      }
      $line = fgets($inf);
      return $line === false || $line === "";
    } finally {
      fclose($inf);
    }
  }

  private static function get_ovsuf($file, $overwrite) {
    if (is_file($file)) {
      if ($overwrite) return " (OVERWRITE)";
      else return "";
    } else return " (NEW)";
  }

  private static function write($line, $w, $tmpf) {
    $r = fwrite($w, $line);
    if ($r === false) {
      fclose($w);
      throw new Exception("$tmpf: erreur lors de l'écriture du fichier");
    }
  }
  private static function write_lines($lines, $outf, $overwrite=true, $final_nl=true) {
    if (is_file($outf) && !$overwrite) {
      return false;
    }

    $dir = dirname($outf);
    if (!is_dir($dir)) {
      $r = mkdir($dir, 0777, true);
      if ($r === false) {
        throw new Exception("$dir: erreur lors de la création du répertoire");
      }
    }

    $tmpf = "$outf.tmp";
    $w = fopen($tmpf, "w+b");
    if ($w === false) {
      throw new Exception("$tmpf: erreur d'ouverture du fichier");
    }
    $first = true;
    foreach ($lines as $line) {
      if ($first) $first = false;
      else self::write("\n", $w, $tmpf);
      self::write($line, $w, $tmpf);
    }
    if ($final_nl) self::write("\n", $w, $tmpf);
    $r = fclose($w);
    if ($r === false) {
      throw new Exception("$tmpf: erreur lors de la fermeture du fichier");
    }
    $r = rename($tmpf, $outf);
    if ($r === false) {
      throw new Exception("$outf: erreur lors du renommage du fichier");
    }
    return true;
  }

  ###

  private static function is_update(string $method) {
    return str::starts_with("update", $method);
  }
  private static function get_index(string $method) {
    return str::without_prefix("update", $method);
  }
  private static function update_methods(array &$methods, string $class): void {
    $all_methods = get_class_methods($class);
    if ($all_methods === null) {
      throw new Exception("$class: classe introuvable");
    }
    $prefix = str_replace("\\", "_", $class);
    foreach ($all_methods as $method) {
      if (!self::is_update($method)) continue;
      $index = self::get_index($method);
      $name = "${prefix}_$index";
      $methods[$name] = [
        "class" => $class,
        "name" => $method,
        "index" => $index,
        "method" => [$class, $method],
        "prefix" => $prefix,
      ];
    }
    $merge_classes = [];
    $c = new ReflectionClass($class);
    $merges = $c->getConstant("MERGES");
    if (base::nz($merges)) A::merge($merge_classes, $merges);
    $merge = $c->getConstant("MERGE");
    if (base::nz($merge)) $merge_classes[] = $merge;
    foreach ($merge_classes as $class) {
      self::update_methods($methods, $class);
    }
  }
  private static function get_methods($class) {
    $methods = [];
    self::update_methods($methods, $class);
    ksort($methods, SORT_NATURAL);
    return $methods;
  }

  ###

  private static function parse_col($def): array {
    if (preg_match('/^([a-zA-Z0-9_]+)(?:\((\d+)\s*(?:,\s*(\d+))?\))?\s*(.*)\s*(?:--\s*(.*)\s*)?(?:--\s*(.*)\s*)?$/', $def, $ms)) {
      $size = base::vn(A::get($ms, 2));
      if ($size !== null) $size = intval($size);
      $precision = base::vn(A::get($ms, 3));
      if ($precision !== null) $precision = intval($precision);
      return [
        "type" => A::get($ms, 1),
        "size" => $size,
        "precision" => $precision,
        "options" => base::vn(A::get($ms, 4)),
        "title" => base::vn(A::get($ms, 5)),
        "desc" => base::vn(A::get($ms, 6)),
      ];
    } else {
      throw new ValueException("erreur de syntaxe: $def");
    }
  }
  private static function is_multi_fkdef($def, &$ms) {
    return preg_match('/^\((.+)\)$/', $def, $ms);
  }
  private static function is_single_fkref($ref, &$ms) {
    return preg_match('/^([a-zA-Z0-9_]+)\(([a-zA-Z0-9_]+)\)$/', $ref, $ms);
  }

  private static $tables = [];

  private static function has_table($table) {
    return A::get(self::$tables, $table, false) !== false;
  }
  private static function has_col($table, $col) {
    return (self::has_table($table) &&
      A::get(self::$tables[$table], $col, false) !== false);
  }
  private static function add_table($table, $suffix=null, $dbtrait=null, $name=null): ?array {
    if (self::has_table($table)) return null;
    if ($name === null) $name = $table;
    return self::$tables[$table] = [
      "name" => $name,
      "suffix" => $suffix,
      "cols" => [],
      "pk" => null,
      "dbtrait" => $dbtrait,
    ];
  }
  private static function set_table_pk($table, $pk) {
    self::add_table($table);
    self::$tables[$table]["pk"] = A::with($pk);
  }
  private static function add_col($table, $col, $def=null): array {
    self::add_table($table);
    if ($def !== null) {
      [
        "type" => $type, "size" => $size, "precision" => $precision,
        "options" => $options,
        "title" => $title, "desc" => $desc,
      ] = self::parse_col($def);
      #XXX calculer le type du schema: bool si integer(1), integer si integer, etc.

      $pk_col = boolval(preg_match('/\bprimary\s+key\b/i', $options));
      $auto_col = boolval(preg_match('/\bauto_increment\b/i', $options));
      if (str::starts_with("datetime", $type)) {
        $hot_type = "date"; #XXX ajouter le support datetime
      } elseif (str::starts_with("date", $type)) {
        $hot_type = "date";
      } elseif (str::starts_with("int", $type)) {
        $hot_type = "numeric";
      } else {
        $hot_type = "text";
      }

      $cinfos = [
        "name" => $col,
        "def" => $def,
        "pk" => $pk_col,
        "auto" => $auto_col,
        "hot_type" => $hot_type,
        #
        "schema" => [
          "name" => $col,
          "title" => $title,
          "desc" => $desc,
          "mysql" => [
            "type" => $type,
            "size" => $size,
            "precision" => $precision,
            "options" => $options,
            "pk" => $pk_col,
            "auto" => $auto_col,
          ],
          "hot" => [
            "type" => $hot_type,
          ],
        ],
      ];
    } else {
      $cinfos = [
        "name" => $col,
      ];
    }
    return self::$tables[$table]["cols"][$col] = $cinfos;
  }
  private static function del_table($table) {
    unset(self::$tables[$table]);
  }
  private static function del_col($table, $col) {
    if (!self::has_table($table)) return;
    unset(self::$tables[$table]["cols"][$col]);
  }

  private static $views = [];

  private static function has_view($view) {
    return A::get(self::$views, $view, false) !== false;
  }
  private static function add_view($view, $tables, $dbtrait=null, $name=null) {
    if (self::has_view($view)) return;
    if ($name === null) $name = $view;
    self::$views[$view] = [
      "name" => $name,
      "tables" => $tables,
      "dbtrait" => $dbtrait,
    ];
  }
  private static function del_view($view) {
    unset(self::$views[$view]);
  }

  private static $t1links = [];
  private static $tmlinks = [];

  private static function add_t1link($scol, $stable, $dcol, $dtable) {
    self::$t1links[] = [$scol, $stable, $dcol, $dtable];
  }

  private static function reset($options=null, $tables_only=false) {
    self::$tables = [];
    self::$views = [];
    self::$t1links = [];
    self::$tmlinks = [];
    if (!$tables_only) {
      self::$trace = A::get($options, "trace");
    }
  }

  ###

  static function gensql($class, $options=null) {
    self::reset($options);
    $destdir = A::get($options, "destdir", ".");
    $overwrite = A::get($options, "overwrite");
    $verbose = A::get($options, "verbose");
    $prefix = A::get($options, "prefix");

    self::trace("# gensql");
    self::trace("  class: $class");
    self::trace("  destdir: $destdir");

    $methods = self::get_methods($class);
    foreach ($methods as $minfos) {
      self::reset(null, true);
      $update = func::call($minfos["method"]);

      # par défaut, ne pas écraser les fichiers qui sont en +prod
      $prod = A::get($update, "+prod");
      $overwrite_sql = $overwrite === null? !$prod: $overwrite;

      self::trace("  ## $minfos[class]::$minfos[name]");

      $lines = [
        "-- -*- coding: utf-8 mode: sql -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8",
        "-- fichier autogénéré: toute modification locale risque d'être perdue",
      ];
      $desc = A::get($update, "+desc");
      if ($desc) $lines[] = "-- $desc";

      foreach ($update as $uname => $uinfos) {
        # ignorer les directives de mise à jour: elles ont été traitées ci-dessus
        if (str::starts_with("+", $uname)) {
          if (!in_array($uname, self::UPDATE_DIRECTIVES)) {
            trigger_error("$uname: unknown update directive", E_USER_WARNING);
          }
          continue;
        }

        $type = A::get($uinfos, "+type", "table");
        if ($type === "table") {
          # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          $table = $uname;
          $table_name = A::get($uinfos, "+name", $table);
          $cols = $uinfos;

          #####################################################################
          # suppression de table
          if ($cols === null) {
            $lines[] = "drop table $table_name;";
            self::del_table($table);
            continue;
          }

          # liste de champs "normale"
          if (!self::has_table($table)) {
            ###################################################################
            # nouvelle table
            $lines[] = "";
            $desc = A::get($cols, "+desc");
            if ($desc) $lines[] = "-- $desc";
            $lines[] = "create table $table_name (";
            $suffix = A::get($cols, "+suffix");
            self::add_table($table, $suffix, null, $table_name);
            $first = true;
            foreach ($cols as $col => $def) {
              # les directives seront traitées plus tard
              if (str::starts_with("+", $col)) continue;
              $lines[] = ($first? " ": ",")."$col $def";
              $cinfos = self::add_col($table, $col, $def);
              if ($cinfos["pk"]) self::set_table_pk($table, $col);
              $first = false;
            }
            foreach ($cols as $col => $value) {
              if (!str::starts_with("+", $col)) continue;
              switch ($col) {
              case "+primary key":
                $pk = A::with($value);
                self::set_table_pk($table, $pk);
                $pk = implode(", ", $pk);
                $lines[] = ",primary key ($pk)";
                break;
              case "+foreign key":
                foreach (A::with($value) as $fk => $ref) {
                  $ms = null;
                  if (self::is_multi_fkdef($fk, $ms)) $fk = $ms[1];
                  $lines[] = ",foreign key ($fk) references $ref";
                  $ms = null;
                  if (self::is_single_fkref($ref, $ms)) {
                    self::add_t1link($fk, $table, $ms[2], $ms[1]);
                  }
                }
                break;
              case "+index":
                foreach (A::with($value) as $name => $cols) {
                  if (is_numeric($name)) $name = false;
                  $cols = implode(", ", A::with($cols));
                  $lines[] = ",index $name($cols)";
                }
                break;
              case "+unique index":
                foreach (A::with($value) as $name => $cols) {
                  if (is_numeric($name)) $name = false;
                  $cols = implode(", ", A::with($cols));
                  $lines[] = ",unique index $name($cols)";
                }
                break;
              default:
                if (!in_array($col, self::TABLE_DIRECTIVES)) {
                  trigger_error("$table: $col: unknown table directive", E_USER_WARNING);
                }
                break;
              }
            }
            $lines[] = ");";
          } else {
            ###################################################################
            # maj d'une table existante
            $lines[] = "";
            $desc = A::get($cols, "+desc");
            if ($desc) $lines[] = "-- $desc";
            foreach ($cols as $col => $def) {
              # les directives seront traitées plus tard
              if (!str::starts_with("+", $col)) {
                if ($def === null) {
                  ## suppression d'un champ
                  if (!self::has_col($table, $col)) {
                    trigger_error("$table.$col: deleting a non-existent column", E_USER_WARNING);
                  }
                  $lines[] = "alter table table_name drop column $col;";
                  self::del_col($table, $col);
                } else {
                  if (self::has_col($table, $col)) {
                    ## maj d'un champ
                    $lines[] = "alter table table_name change column $col $col $def;";
                    # NB: pour simplifier, ne pas tenir compte du fait que ce
                    # nouveau champ pourrait maintenant être une clé primaire
                  } else {
                    ## ajout d'un champ
                    $lines[] = "alter table table_name add column $col $def;";
                    self::add_col($table, $col, $def);
                    # NB: pour simplifier, ne pas tenir compte du fait que ce
                    # nouveau champ pourrait être une clé primaire
                  }
                }
              } else {
                ## maj directive
                trigger_error("$table: $col: table directive update is not (yet) supported", E_USER_WARNING);
              }
            }
          }

        } elseif ($type === "view") {
          # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          $view = $uname;
          $view_name = A::get($uinfos, "+name", $view);
          $vinfos = $uinfos;

          #####################################################################
          # suppression de vue
          if ($vinfos === null) {
            $lines[] = "drop view $view_name;";
            self::del_view($view);
            continue;
          }

          # définition "normale"
          if (!self::has_view($view)) {
            ###################################################################
            # nouvelle vue
            $tables = A::get($vinfos, "+view_tables");
            $def = A::get($vinfos, "+view_def");
            if ($def === null) trigger_error("$view: missing +view_def", E_USER_WARNING);
            self::add_view($view, $tables, null, $view_name);

            $lines[] = "";
            $desc = A::get($vinfos, "+desc");
            if ($desc) $lines[] = "-- $desc";
            $lines[] = "create view $view_name as";
            $lines[] = $def;
            $lines[] = ";";
          } else {
            ###################################################################
            # maj d'une vue existante
            trigger_error("$view: view update is not (yet) supported", E_USER_WARNING);
          }

        } else {
          trigger_error("$type: unknown type, expected table or view", E_USER_WARNING);
        }
      }

      if (self::$tables || self::$views) {
        $name = A::get($update, "+name", "update");
        $index = $minfos["index"];
        $outfname = "$index-$name.sql";
        if ($prefix) $outfname = "$minfos[prefix]--$outfname";
        $outf = "$destdir/$outfname";
        $ovsuf = self::get_ovsuf($outf, $overwrite_sql);
        if ($ovsuf || $verbose) {
          self::trace("    sql output: $outfname$ovsuf");
          self::write_lines($lines, "$outf", $overwrite_sql);
        }

        # les fichiers csv ne sont écrasés que s'ils n'ont pas de données
        # automatiquement
        $overwrite_csv_threshold = $overwrite_sql? 1: false;
        foreach (self::$tables as $table => $tinfos) {
          $table_name = $tinfos["name"];
          $cols = array_keys($tinfos["cols"]);
          $lines = [implode(",", $cols)];
          $index++;
          $outfname = "$index-$table_name.csv";
          if ($prefix) $outfname = "$minfos[prefix]--$outfname";
          $outf = "$destdir/$outfname";
          $overwrite_csv = self::should_overwrite($outf, $overwrite_csv_threshold);
          $ovsuf = self::get_ovsuf($outf, $overwrite_csv);
          if ($ovsuf || $verbose) {
            self::trace("    csv output: $outfname$ovsuf");
            self::write_lines($lines, "$outf", $overwrite_csv);
          }
        }
      }
    }
  }

  static function gendoc($class, $options=null) {
    self::reset($options);
    $destdir = A::get($options, "destdir", ".");
    $overwrite = A::get($options, "overwrite");
    $verbose = A::get($options, "verbose");

    # par défaut, écraser les fichiers lors de la génération de la doc
    if ($overwrite === null) $overwrite = true;

    self::trace("# gendoc");
    self::trace("  class: $class");
    self::trace("  destdir: $destdir");

    $methods = self::get_methods($class);
    foreach ($methods as $minfos) {
      $update = func::call($minfos["method"]);

      foreach ($update as $uname => $uinfos) {
        # ignorer les directives de mise à jour: elles ont été traitées ci-dessus
        if (str::starts_with("+", $uname)) {
          if (!in_array($uname, self::UPDATE_DIRECTIVES)) {
            trigger_error("$uname: unknown update directive", E_USER_WARNING);
          }
          continue;
        }

        $type = A::get($uinfos, "+type", "table");
        if ($type === "table") {
          # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          $table = $uname;
          $cols = $uinfos;

          #####################################################################
          # suppression de table
          if ($cols === null) {
            self::del_table($table);
            continue;
          }

          # liste de champs "normale"
          if (!self::has_table($table)) {
            ###################################################################
            # nouvelle table
            self::add_table($table);
            foreach ($cols as $col => $value) {
              if (!str::starts_with("+", $col)) {
                self::add_col($table, $col);
              } else {
                switch ($col) {
                case "+foreign key":
                  foreach (A::with($value) as $fk => $ref) {
                    $ms = null;
                    if (self::is_multi_fkdef($fk, $ms)) $fk = $ms[1];
                    if (self::is_single_fkref($ref, $ms)) {
                      self::add_t1link($fk, $table, $ms[2], $ms[1]);
                    }
                  }
                  break;
                default:
                  if (!in_array($col, self::TABLE_DIRECTIVES)) {
                    trigger_error("$table: $col: unknown table directive", E_USER_WARNING);
                  }
                  break;
                }
              }
            }
          } else {
            ###################################################################
            # maj d'une table existante
            foreach ($cols as $col => $def) {
              # les directives seront traitées plus tard
              if (!str::starts_with("+", $col)) {
                if ($def === null) {
                  ## suppression d'un champ
                  if (!self::has_col($table, $col)) {
                    trigger_error("$table.$col: deleting a non-existent column", E_USER_WARNING);
                  }
                  self::del_col($table, $col);
                } else {
                  if (!self::has_col($table, $col)) {
                    self::add_col($table, $col);
                  }
                }
              } else {
                ## maj directive
                trigger_error("$table: $col: table directive update is not (yet) supported", E_USER_WARNING);
              }
            }
          }

        } elseif ($type === "view") {
          # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          $view = $uname;
          $vinfos = $uinfos;

          #####################################################################
          # suppression de vue
          if ($vinfos === null) {
            self::del_view($view);
            continue;
          }

          # définition "normale"
          if (!self::has_view($view)) {
            ###################################################################
            # nouvelle vue
            $tables = A::get($vinfos, "+view_tables", null);
            self::add_view($view, $tables);

          } else {
            ###################################################################
            # maj d'une vue existante
            trigger_error("$view: view update is not (yet) supported", E_USER_WARNING);
          }

        } else {
          trigger_error("$type: unknown type, expected table or view", E_USER_WARNING);
        }
      }
    }

    $lines = [
      "# -*- coding: utf-8 mode: plantuml -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8",
      "# fichier autogénéré: toute modification locale risque d'être perdue",
      "",
      "##@startuml",
      "hide empty members",
      "hide empty methods",
      "hide circle",
      "!ifndef SHOW_DETAILS",
      "hide members",
      "!endif",
    ];
    foreach (self::$tables as $table => $tinfos) {
      $lines[] = "";
      $lines[] = "class $table {";
      $cols = $tinfos["cols"];
      foreach (array_keys($cols) as $col) {
        $lines[] = "  $col";
      }
      $lines[] = "}";
    }
    foreach (self::$views as $view => $vinfos) {
      $lines[] = "";
      $lines[] = "class $view<view> {";
      $tables = $vinfos["tables"];
      foreach ($tables as $table) {
        $cols = self::$tables[$table]["cols"];
        foreach (array_keys($cols) as $col) {
          $lines[] = "  $col";
        }
      }
      $lines[] = "}";
    }
    $lines[] = "";
    $lines[] = "##@enduml";

    $outfname = "classes.iuml";
    $outf = "$destdir/$outfname";
    $ovsuf = self::get_ovsuf($outf, $overwrite);
    if ($ovsuf || $verbose) {
      self::trace("  puml output: $outfname$ovsuf");
      self::write_lines($lines, $outf, $overwrite, true);
    }

    $lines = [];
    foreach (self::$tables as $table => $tinfos) {
      $lines[] = "'hide $table";
    }
    $first = true;
    foreach (self::$t1links as $link) {
      if ($first) $lines[] = "";
      list($scol, $stable, $dcol, $dtable) = $link;
      $lines[] = "$stable \"*\" -- \"1\" $dtable";
      $first = false;
    }
    A::merge($lines, [
      "~~~",
      "",
      "-*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary",
    ]);

    $slines = [
      "![Modèle simplifié](model-simple.png)",
      "~~~puml",
      "# output=model-simple.png",
      "!include classes.iuml",
    ];
    A::merge($slines, $lines);
    $outfname = "model-simple.md";
    $outf = "$destdir/$outfname";
    $ovsuf = self::get_ovsuf($outf, $overwrite);
    if ($ovsuf || $verbose) {
      self::trace("  md output: $outfname$ovsuf");
      self::write_lines($slines, $outf, $overwrite, false);
    }

    $dlines = [
      "![Modèle détaillé](model-details.png)",
      "~~~puml",
      "# output=model-details.png",
      "!define SHOW_DETAILS",
      "!include classes.iuml",
    ];
    A::merge($dlines, $lines);
    $outfname = "model-details.md";
    $outf = "$destdir/$outfname";
    $ovsuf = self::get_ovsuf($outf, $overwrite);
    if ($ovsuf || $verbose) {
      self::trace("  md output: $outfname$ovsuf");
      self::write_lines($dlines, $outf, $overwrite, false);
    }
  }

  private static function add_vtables(&$lines, $cols) {
    $lines[] = "";
    $lines[] = "  const VTABLES_HEADERS = self::COLUMNS;";
    foreach ($cols as $col => $cinfos) {
      $vcol = strtoupper($col)."_VCOL";
      $lines[] = "  const $vcol = [";
      $lines[] = "    \"data\" => \"$col\",";
      $lines[] = "    \"type\" => \"$cinfos[hot_type]\",";
      if ($cinfos["hot_type"] == "date") {
        $lines[] = "    \"dateFormat\" => \"DD/MM/YYYY\",";
        $lines[] = "    \"correctFormat\" => true,";
      }
      $lines[] = "  ];";
    }
    $lines[] = "  const VTABLES_SCHEMA = [";
    foreach (array_keys($cols) as $col) {
      $vcol = strtoupper($col)."_VCOL";
      $lines[] = "    self::$vcol,";
    }
    $lines[] = "  ];";
  }
  private static function add_data_schema(&$lines, $cols) {
    $cols = array_keys($cols);
    $lines[] = "";
    foreach ($cols as $col) {
      $dcol = strtoupper($col)."_DCOL";
      $lines[] = "  const $dcol = [";
      $lines[] = "    \"name\" => \"$col\",";
      $lines[] = "    \"key\" => \"$col\",";
      $lines[] = "  ];";
    }
    $lines[] = "  const DATA_SCHEMA = [";
    foreach ($cols as $col) {
      $dcol = strtoupper($col)."_DCOL";
      $lines[] = "    self::$dcol,";
    }
    $lines[] = "  ];";
    $lines[] = "  const CSV_SCHEMA = self::DATA_SCHEMA;";
    $lines[] = "  const HTML_SCHEMA = self::DATA_SCHEMA;";
  }
  private static function add_schema(array &$lines, array $cols, string $indent): void {
    $schema = [];
    foreach ($cols as $col) {
      $schema[$col["name"]] = $col["schema"];
    }
    md::normalize_mschema($schema);
    $generator = new SrcGenerator($indent);
    $generator->genConst("SCHEMA", $schema);
    $generator->mergeInto($lines);
  }

  static function genclass($class, $options=null) {
    $c = new ReflectionClass($class);
    $force_dbtrait = $c->getConstant("DBTRAIT");
    $default_dbtrait = $c->getConstant("DEFAULT_DBTRAIT");

    self::reset($options);
    $destdir = A::get($options, "destdir", ".");
    $package = A::get($options, "package");
    $baserow = A::get($options, "baserow");
    $overwrite = A::get($options, "overwrite");
    $verbose = A::get($options, "verbose");
    if ($baserow === null) $baserow = AbstractRow::class;

    $pos = strrpos($class, "\\");
    $default_package = $pos !== false? substr($class, 0, $pos): false;
    if ($package === null) $package = $default_package;
    elseif (substr($package, 0, 1) != "\\") $package = "$default_package\\$package";
    else $package = substr($package, 1);

    # par défaut ne pas écraser les fichiers lors de la génération des classes
    if ($overwrite === null) $overwrite = false;
    # ne pas écraser les classes dont la définition est en +prod
    $no_overwrites = [];

    self::trace("# genclass");
    self::trace("  class: $class");
    self::trace("  destdir: $destdir");

    $methods = self::get_methods($class);
    foreach ($methods as $minfos) {
      $update = func::call($minfos["method"]);
      $dbtrait = $force_dbtrait;
      if (!$dbtrait) $dbtrait = A::get($update, "+dbtrait");
      if (!$dbtrait) $dbtrait = $default_dbtrait;
      $prod = A::get($update, "+prod");

      foreach ($update as $uname => $uinfos) {
        # ignorer les directives de mise à jour: elles ont été traitées ci-dessus
        if (str::starts_with("+", $uname)) {
          if (!in_array($uname, self::UPDATE_DIRECTIVES)) {
            trigger_error("$uname: unknown update directive", E_USER_WARNING);
          }
          continue;
        }

        $type = A::get($uinfos, "+type", "table");
        if ($type === "table") {
          # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          $table = $uname;
          $table_name = A::get($uinfos, "+name", $table);
          $cols = $uinfos;

          #######################################################################
          # suppression de table
          if ($cols === null) {
            self::del_table($table);
            if ($prod) unset($no_overwrites[$table]);
            continue;
          }

          # liste de champs "normale"
          if (!self::has_table($table)) {
            #####################################################################
            # nouvelle table
            $suffix = A::get($cols, "+suffix");
            self::add_table($table, $suffix, $dbtrait, $table_name);
            if ($prod) $no_overwrites[$table] = true;
            foreach ($cols as $col => $value) {
              if (!str::starts_with("+", $col)) {
                $def = $value;
                $cinfos = self::add_col($table, $col, $def);
                if ($cinfos["pk"]) self::set_table_pk($table, $col);
              } else {
                switch ($col) {
                case "+primary key":
                  $pk = A::with($value);
                  self::set_table_pk($table, $pk);
                  break;
                case "+foreign key":
                  foreach (A::with($value) as $fk => $ref) {
                    $ms = null;
                    if (self::is_multi_fkdef($fk, $ms)) $fk = $ms[1];
                    if (self::is_single_fkref($ref, $ms)) {
                      self::add_t1link($fk, $table, $ms[2], $ms[1]);
                    }
                  }
                  break;
                default:
                  if (!in_array($col, self::TABLE_DIRECTIVES)) {
                    trigger_error("$table: $col: unknown table directive", E_USER_WARNING);
                  }
                  break;
                }
              }
            }
          } else {
            #####################################################################
            # maj d'une table existante
            foreach ($cols as $col => $def) {
              # les directives seront traitées plus tard
              if (!str::starts_with("+", $col)) {
                if ($def === null) {
                  ## suppression d'un champ
                  if (!self::has_col($table, $col)) {
                    trigger_error("$table.$col: deleting a non-existent column", E_USER_WARNING);
                  }
                  self::del_col($table, $col);
                } else {
                  if (!self::has_col($table, $col)) {
                    self::add_col($table, $col, $def);
                  }
                }
              } else {
                ## maj directive
                trigger_error("$table: $col: table directive update is not (yet) supported", E_USER_WARNING);
              }
            }
          }

        } elseif ($type === "view") {
          # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          $view = $uname;
          $view_name = A::get($uinfos, "+name", $view);
          $vinfos = $uinfos;

          #####################################################################
          # suppression de vue
          if ($vinfos === null) {
            self::del_view($view);
            if ($prod) unset($no_overwrites[$view]);
            continue;
          }

          # définition "normale"
          if (!self::has_view($view)) {
            ###################################################################
            # nouvelle vue
            $tables = A::get($vinfos, "+view_tables");
            self::add_view($view, $tables, $dbtrait, $view_name);
            if ($prod) $no_overwrites[$view] = true;

          } else {
            ###################################################################
            # maj d'une vue existante
            trigger_error("$view: view update is not (yet) supported", E_USER_WARNING);
          }

        } else {
          trigger_error("$type: unknown type, expected table or view", E_USER_WARNING);
        }
      }
    }

    foreach (self::$tables as $table => $tinfos) {
      $class = str::upperw($table);
      $class = preg_replace('/[^a-zA-Z0-9]*/', "", $class);

      $suffix = $tinfos["suffix"];
      $suffix = $suffix? "\"$suffix\"": "null";

      $pk = $tinfos["pk"];
      $pk_key = [];
      if ($pk !== null) {
        foreach ($pk as $key) {
          $pk_key[] = "\"$key\"";
        }
      }
      if (!$pk_key) {
        $pk_key = "false";
      } elseif (\count($pk_key) == 1) {
        $pk_key = $pk_key[0];
      } else {
        $pk_key = "[".implode(", ", $pk_key)."]";
      }

      $pk_auto = false;
      $cols = $tinfos["cols"];
      foreach ($cols as $cinfos) {
        if ($cinfos["auto"]) {
          $pk_auto = true;
          break;
        }
      }
      $pk_auto = $pk_auto? "true": "false";

      $dbtrait = $tinfos["dbtrait"];
      $table_name = $tinfos["name"];

      ###

      $lines = ["<?php"];
      if ($package) $lines[] = "namespace $package;";
      $lines[] = "";
      $lines[] = "class _$class extends \\$baserow {";
      if ($dbtrait) {
        $lines[] = "  use \\$dbtrait;";
        $lines[] = "";
      }
      $lines[] = "  const TABLE_NAME = \"$table_name\";";
      $lines[] = "  const COL_SUFFIX = $suffix;";
      $lines[] = "  const PK_KEY = $pk_key;";
      $lines[] = "  const PK_AUTO = $pk_auto;";
      $lines[] = "  const COLUMNS = [";
      foreach (array_keys($cols) as $col) {
        $lines[] = "    \"$col\",";
      }
      $lines[] = "  ];";

      #XXX désactiver pour le moment
      #self::add_vtables($lines, $cols);
      #self::add_data_schema($lines, $cols);
      $lines[] = "}";

      $overwrite_class = $overwrite || !A::has($no_overwrites, $table);
      $outfname = "_$class.php";
      $outf = "$destdir/$outfname";
      $ovsuf = self::get_ovsuf($outf, $overwrite_class);
      if ($ovsuf || $verbose) {
        self::trace("  php output: $outfname$ovsuf");
        self::write_lines($lines, $outf, $overwrite_class, true);
      }

      ###

      $lines = ["<?php"];
      if ($package) $lines[] = "namespace $package;";
      $lines[] = "";
      $lines[] = "class $class extends _$class {";
      $lines[] = "}";

      # les fichiers Class ne sont jamais écrasés automatiquement
      $overwrite_class = false; #$overwrite && !A::has($no_overwrites, $table);
      $outfname = "$class.php";
      $outf = "$destdir/$outfname";
      $ovsuf = self::get_ovsuf($outf, $overwrite_class);
      if ($ovsuf || $verbose) {
        self::trace("  php output: $outfname$ovsuf");
        self::write_lines($lines, $outf, $overwrite_class, true);
      }
    }

    foreach (self::$views as $view => $vinfos) {
      $class = str::upperw($view);
      $class = preg_replace('/[^a-zA-Z0-9]*/', "", $class);

      $cols = [];
      foreach ($vinfos["tables"] as $table) {
        $cols = array_merge($cols, self::$tables[$table]["cols"]);
      }

      $dbtrait = $tinfos["dbtrait"];
      $view_name = $vinfos["name"];

      ###

      $lines = ["<?php"];
      if ($package) $lines[] = "namespace $package;";
      $lines[] = "";
      $lines[] = "class _$class extends $baserow {";
      if ($dbtrait) {
        $lines[] = "  use \\$dbtrait;";
        $lines[] = "";
      }
      $lines[] = "  const TABLE_NAME = \"$view_name\";";
      $lines[] = "  const COL_SUFFIX = null;";
      $lines[] = "  const PK_KEY = false;";
      $lines[] = "  const PK_AUTO = false;";
      $lines[] = "  const COLUMNS = [";
      foreach (array_keys($cols) as $col) {
        $lines[] = "    \"$col\",";
      }
      $lines[] = "  ];";

      #XXX désactiver pour le moment
      #self::add_vtables($lines, $cols);
      #self::add_data_schema($lines, $cols);
      $lines[] = "}";

      $overwrite_class = $overwrite || !A::has($no_overwrites, $table);
      $outfname = "_$class.php";
      $outf = "$destdir/$outfname";
      $ovsuf = self::get_ovsuf($outf, $overwrite_class);
      if ($ovsuf || $verbose) {
        self::trace("  php output: $outfname$ovsuf");
        self::write_lines($lines, $outf, $overwrite_class, true);
      }

      ###

      $lines = ["<?php"];
      if ($package) $lines[] = "namespace $package;";
      $lines[] = "";
      $lines[] = "class $class extends _$class {";
      $lines[] = "}";

      # les fichiers Class ne sont jamais écrasés automatiquement
      $overwrite_class = false; #$overwrite && !A::has($no_overwrites, $view);
      $outfname = "$class.php";
      $outf = "$destdir/$outfname";
      $ovsuf = self::get_ovsuf($outf, $overwrite_class);
      if ($ovsuf || $verbose) {
        self::trace("  php output: $outfname$ovsuf");
        self::write_lines($lines, $outf, $overwrite_class, true);
      }
    }
  }
}