<?php

/**

 * @intranet-file base/plugin_builder/index.php

 * @intranet-file-version 2026.03.13.1

 * @intranet-file-date 2026-03-13

 * @intranet-file-owner CSIF Intranet

 */

declare(strict_types=1);



require_once dirname(__DIR__, 2) . '/common/app_guard.php';
require_once dirname(__DIR__, 2) . '/common/url.php';
require_once dirname(__DIR__, 2) . '/common/roles.php';
checkAppAccess();


require_once dirname(__DIR__, 2) . '/common/catalogo_db.php';

require_once dirname(__DIR__, 2) . '/common/db.php';

require_once dirname(__DIR__, 2) . '/common/plugin_store.php';



if (!catalogo_is_admin()) {

  renderErrorPage('No tienes permisos para acceder a <em>Plugin Builder</em>.');

  exit;

}



function h(string $s): string

{

  return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

}



function pb_semver_next(string $current, string $bump): string

{

  $m = [];

  if (!preg_match('/^\s*(\d+)\.(\d+)\.(\d+)\s*$/', $current, $m)) {

    $a = 1;

    $b = 0;

    $c = 0;

  } else {

    $a = (int) $m[1];

    $b = (int) $m[2];

    $c = (int) $m[3];

  }



  $bump = strtolower(trim($bump));

  if ($bump === 'a' || $bump === 'major') {

    $a++;

    $b = 0;

    $c = 0;

  } elseif ($bump === 'b' || $bump === 'minor') {

    $b++;

    $c = 0;

  } else {

    $c++;

  }



  return $a . '.' . $b . '.' . $c;

}



$pdo = db();



$publicRoot = realpath(__DIR__ . '/../../');

$appsPath = $publicRoot . '/apps';

$buildOutDir = $publicRoot . '/apps/backups';

@mkdir($buildOutDir, 0775, true);



$csrfKey = 'plugin_builder_csrf';

if (empty($_SESSION[$csrfKey])) {

  $_SESSION[$csrfKey] = bin2hex(random_bytes(16));

}

$csrf = $_SESSION[$csrfKey];



$templateInstaller = __DIR__ . '/_templates/plugin_installer.tpl.php';

$templateUninstaller = __DIR__ . '/_templates/plugin_uninstaller.tpl.php';



function list_apps(string $appsPath): array

{

  $out = [];

  $items = @scandir($appsPath);

  if (!$items) {

    return $out;

  }



  foreach ($items as $it) {

    if ($it === '.' || $it === '..') {

      continue;

    }

    if ($it[0] === '_') {

      continue;

    }

    $full = $appsPath . DIRECTORY_SEPARATOR . $it;

    if (!is_dir($full)) {

      continue;

    }

    if (in_array($it, ['plugin_manager', 'plugin_builder', 'plugins'], true)) {

      continue;

    }

    $out[] = $it;

  }



  sort($out);

  return $out;

}



function slugify(string $s): string

{

  $s = strtolower(trim($s));

  $s = preg_replace('~[^a-z0-9]+~', '-', $s);

  $s = trim($s, '-');

  return $s ?: 'plugin';

}



function copy_tree(string $src, string $dst, array &$errors): void
{
  $src = rtrim($src, "/\\");

  $dst = rtrim($dst, "/\\");

  if (!is_dir($src)) {

    $errors[] = "No existe la app: $src";

    return;

  }



  @mkdir($dst, 0775, true);



  $rii = new RecursiveIteratorIterator(

    new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),

    RecursiveIteratorIterator::SELF_FIRST

  );



  foreach ($rii as $file) {
    $rel = substr($file->getPathname(), strlen($src) + 1);
    $rel = str_replace('\\', '/', $rel);

    if (preg_match('~(^|/)(\.git|node_modules|vendor|\.idea|\.vscode|uploads|backups|cache|tmp|temp|logs)(/|$)~', $rel)) {
      continue;
    }

    if (basename($rel) === '.htaccess') {
      continue;
    }


    $target = $dst . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);



    if ($file->isDir()) {

      @mkdir($target, 0775, true);

    } else {

      @mkdir(dirname($target), 0775, true);

      if (!@copy($file->getPathname(), $target)) {

        $errors[] = "No se pudo copiar: $rel";

      }

    }

  }

}



function pb_plugin_public_root_levels(string $file, string $appRoot): int

{

  $fileDir = rtrim(str_replace('\\', '/', dirname($file)), '/');

  $appRoot = rtrim(str_replace('\\', '/', $appRoot), '/');



  if ($fileDir === $appRoot) {

    return 4;

  }



  $rel = ltrim(substr($fileDir, strlen($appRoot)), '/');

  if ($rel === '') {

    return 4;

  }



  $depth = substr_count($rel, '/') + 1;

  return 4 + $depth;

}



function pb_root_expr(int $levels): string

{

  $pluginLevels = max(1, $levels);

  $directLevels = max(1, $levels - 2);



  return '(is_file(dirname(__DIR__, ' . $directLevels . ') . \'/common/app_guard.php\')'

    . ' ? dirname(__DIR__, ' . $directLevels . ')'

    . ' : dirname(__DIR__, ' . $pluginLevels . '))';

}



function pb_replace_with_root_expr(string $content, int $levels): string

{

  $root = pb_root_expr($levels);



  $targets = ['common', 'styles', 'ajax'];



  foreach ($targets as $target) {

    // __DIR__ . '/../../../common/x.php'

    $content = preg_replace(

      '~__DIR__\s*\.\s*[\'"]/+(?:\.\./)+(?:' . preg_quote($target, '~') . ')/(.*?)[\'"]~',

      $root . " . '/" . $target . "/$1'",

      $content

    );



    // __DIR__ . "../../../common/x.php"

    $content = preg_replace(

      '~__DIR__\s*\.\s*[\'"](?:\.\./)+(?:' . preg_quote($target, '~') . ')/(.*?)[\'"]~',

      $root . " . '/" . $target . "/$1'",

      $content

    );



    // dirname(__FILE__) . '/../../../common/x.php'

    $content = preg_replace(

      '~dirname\s*\(\s*__FILE__\s*\)\s*\.\s*[\'"]/+(?:\.\./)+(?:' . preg_quote($target, '~') . ')/(.*?)[\'"]~',

      $root . " . '/" . $target . "/$1'",

      $content

    );



    // dirname(__FILE__) . "../../../common/x.php"

    $content = preg_replace(

      '~dirname\s*\(\s*__FILE__\s*\)\s*\.\s*[\'"](?:\.\./)+(?:' . preg_quote($target, '~') . ')/(.*?)[\'"]~',

      $root . " . '/" . $target . "/$1'",

      $content

    );



    // require/include '../../../common/x.php';

    $content = preg_replace(

      '~\b(require_once|require|include_once|include)\s*\(?\s*[\'"]/+(?:\.\./)+(?:' . preg_quote($target, '~') . ')/(.*?)[\'"]\s*\)?\s*;~',

      '$1 ' . $root . " . '/" . $target . "/$2';",

      $content

    );



    // require/include '../../../common/x.php'; sin slash inicial

    $content = preg_replace(

      '~\b(require_once|require|include_once|include)\s*\(?\s*[\'"](?:\.\./)+(?:' . preg_quote($target, '~') . ')/(.*?)[\'"]\s*\)?\s*;~',

      '$1 ' . $root . " . '/" . $target . "/$2';",

      $content

    );

  }



  // dashboard.php

  $content = preg_replace(

    '~__DIR__\s*\.\s*[\'"]/+(?:\.\./)+dashboard\.php[\'"]~',

    $root . " . '/dashboard.php'",

    $content

  );

  $content = preg_replace(

    '~__DIR__\s*\.\s*[\'"](?:\.\./)+dashboard\.php[\'"]~',

    $root . " . '/dashboard.php'",

    $content

  );

  $content = preg_replace(

    '~dirname\s*\(\s*__FILE__\s*\)\s*\.\s*[\'"]/+(?:\.\./)+dashboard\.php[\'"]~',

    $root . " . '/dashboard.php'",

    $content

  );

  $content = preg_replace(

    '~dirname\s*\(\s*__FILE__\s*\)\s*\.\s*[\'"](?:\.\./)+dashboard\.php[\'"]~',

    $root . " . '/dashboard.php'",

    $content

  );

  $content = preg_replace(

    '~\b(require_once|require|include_once|include)\s*\(?\s*[\'"]/+(?:\.\./)+dashboard\.php[\'"]\s*\)?\s*;~',

    '$1 ' . $root . " . '/dashboard.php';",

    $content

  );

  $content = preg_replace(

    '~\b(require_once|require|include_once|include)\s*\(?\s*[\'"](?:\.\./)+dashboard\.php[\'"]\s*\)?\s*;~',

    '$1 ' . $root . " . '/dashboard.php';",

    $content

  );



  return $content;

}



function pb_pluginize_php_paths(string $file, string $appRoot, array &$errors): void

{

  $raw = @file_get_contents($file);

  if ($raw === false || $raw === '') {

    return;

  }



  $original = $raw;

  $levels = pb_plugin_public_root_levels($file, $appRoot);

  $raw = pb_replace_with_root_expr($raw, $levels);



  if ($raw !== $original) {

    if (@file_put_contents($file, $raw) === false) {

      $errors[] = "No se pudo reescribir rutas en: $file";

    }

  }

}



function pb_pluginize_app_tree(string $appDir, array &$errors): void

{

  if (!is_dir($appDir)) {

    return;

  }



  $rii = new RecursiveIteratorIterator(

    new RecursiveDirectoryIterator($appDir, FilesystemIterator::SKIP_DOTS)

  );



  foreach ($rii as $file) {

    if (!$file->isFile()) {

      continue;

    }



    $ext = strtolower((string) $file->getExtension());

    if (!in_array($ext, ['php', 'phtml', 'inc'], true)) {

      continue;

    }



    pb_pluginize_php_paths($file->getPathname(), $appDir, $errors);

  }

}



function zip_dir(string $srcDir, string $zipFile, array &$errors): void
{
  $zip = new ZipArchive();

  if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {

    $errors[] = "No se pudo crear ZIP: $zipFile";

    return;

  }



  $srcDir = rtrim($srcDir, "/\\");

  $rii = new RecursiveIteratorIterator(

    new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)

  );



  foreach ($rii as $file) {

    if ($file->isDir()) {

      continue;

    }

    $path = $file->getPathname();
    $rel = substr($path, strlen($srcDir) + 1);
    $rel = str_replace('\\', '/', $rel);
    if (strpos($rel, '..') !== false) {
      continue;
    }
    if (basename($rel) === '.htaccess') {
      continue;
    }
    $zip->addFile($path, $rel);
  }
  $zip->close();
}


function intranet_apps_row(PDO $pdo, string $sourceApp): ?array

{

  try {

    $exists = $pdo->query("SHOW TABLES LIKE 'intranet_apps'")->fetch();

    if (!$exists) {

      return null;

    }



    $url = "apps/{$sourceApp}/index.php";

    $st = $pdo->prepare("SELECT * FROM intranet_apps WHERE url = ? LIMIT 1");

    $st->execute([$url]);

    $row = $st->fetch(PDO::FETCH_ASSOC);

    if ($row) {

      return $row;

    }



    $st = $pdo->prepare("SELECT * FROM intranet_apps WHERE url LIKE ? ORDER BY id DESC LIMIT 1");

    $st->execute(["apps/{$sourceApp}/%"]);

    $row = $st->fetch(PDO::FETCH_ASSOC);

    return $row ?: null;

  } catch (Throwable $e) {

    return null;

  }

}



function pb_cache_row(PDO $pdo, string $sourceApp): ?array

{

  try {

    $exists = $pdo->query("SHOW TABLES LIKE 'plugin_builder_apps'")->fetch();

    if (!$exists) {

      return null;

    }

    $st = $pdo->prepare("SELECT * FROM plugin_builder_apps WHERE source_app=? LIMIT 1");

    $st->execute([$sourceApp]);

    $r = $st->fetch(PDO::FETCH_ASSOC);

    return $r ?: null;

  } catch (Throwable $e) {

    return null;

  }

}



function pb_upsert_builder_cache(PDO $pdo, array $m, array &$errors): void

{

  try {

    $exists = $pdo->query("SHOW TABLES LIKE 'plugin_builder_apps'")->fetch();

    if (!$exists) {

      return;

    }



    $st = $pdo->prepare("

      INSERT INTO plugin_builder_apps

      (source_app, plugin_id, app_folder, name, version, description, icon, roles_json, new_tab, default_group_name)

      VALUES (?,?,?,?,?,?,?,?,?,?)

      ON DUPLICATE KEY UPDATE

        plugin_id=VALUES(plugin_id),

        app_folder=VALUES(app_folder),

        name=VALUES(name),

        version=VALUES(version),

        description=VALUES(description),

        icon=VALUES(icon),

        roles_json=VALUES(roles_json),

        new_tab=VALUES(new_tab),

        default_group_name=VALUES(default_group_name),

        updated_at=NOW()

    ");

    $st->execute([

      (string) $m['source_app'],

      (string) $m['plugin_id'],

      (string) $m['app_folder'],

      (string) $m['name'],

      (string) $m['version'],

      (string) ($m['description'] ?? ''),

      (string) ($m['icon'] ?? ''),

      (string) ($m['roles_json_str'] ?? ''),

      (int) ($m['new_tab'] ?? 0),

      (string) ($m['default_group_name'] ?? '')

    ]);

  } catch (Throwable $e) {

    $errors[] = "Aviso: no se pudo guardar caché builder (plugin_builder_apps): " . $e->getMessage();

  }

}



function pb_update_intranet_apps(PDO $pdo, array $m, array &$errors): void

{

  try {

    $exists = $pdo->query("SHOW TABLES LIKE 'intranet_apps'")->fetch();

    if (!$exists) {

      return;

    }



    $url = "/apps/" . $m['source_app'] . "/index.php";

    $st = $pdo->prepare("SELECT id FROM intranet_apps WHERE url=? LIMIT 1");

    $st->execute([$url]);

    $row = $st->fetch(PDO::FETCH_ASSOC);

    if (!$row) {

      return;

    }



    $up = $pdo->prepare("

      UPDATE intranet_apps

      SET name=?, description=?, icon=?, roles_json=?, new_tab=?, group_name=?

      WHERE url=?

    ");

    $up->execute([

      (string) $m['name'],

      (string) ($m['description'] ?? ''),

      (string) ($m['icon'] ?? ''),

      (string) ($m['roles_json_str'] ?? ''),

      (int) ($m['new_tab'] ?? 0),

      (string) ($m['default_group_name'] ?? 'Mis apps'),

      $url

    ]);

  } catch (Throwable $e) {

    $errors[] = "Aviso: no se pudo actualizar intranet_apps: " . $e->getMessage();

  }

}



function pb_app_json_payload(array $m): array
{
  $roles = json_decode((string) ($m['roles_json_str'] ?? '[]'), true);
  if (!is_array($roles)) {
    $roles = [];
  }
  $scheduledTasks = pb_normalize_scheduled_tasks((array) ($m['scheduled_tasks'] ?? []));
  $supportScriptErrors = [];
  $supportScripts = pb_normalize_support_scripts((array) ($m['support_scripts'] ?? []), $supportScriptErrors);
  $changelog = pb_changelog_normalize((array) ($m['changelog'] ?? []));

  $updatedAt = trim((string) ($m['updated_at'] ?? ''));
  if ($updatedAt === '') {
    $updatedAt = date('c');
  }

  return [
    'source_app' => (string) $m['source_app'],
    'plugin_id' => (string) $m['plugin_id'],

    'app_folder' => (string) $m['app_folder'],

    'name' => (string) $m['name'],

    'version' => (string) $m['version'],

    'description' => (string) ($m['description'] ?? ''),
    'icon' => (string) ($m['icon'] ?? ''),
    'roles_json' => $roles,
    'sql_scripts' => array_values(array_filter($m['sql_scripts'] ?? [], static fn($item) => is_array($item))),
    'scheduled_tasks' => $scheduledTasks,
    'support_scripts' => $supportScripts,
    'changelog' => $changelog,
    'new_tab' => (int) ($m['new_tab'] ?? 0),
    'default_group_name' => (string) ($m['default_group_name'] ?? 'Mis apps'),
    'updated_at' => $updatedAt,
  ];
}


function pb_write_app_json_file(string $path, array $m, array &$errors): void

{

  $data = pb_app_json_payload($m);

  $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

  if ($json === false) {

    return;

  }



  $dir = dirname($path);

  if (file_exists($path) && !is_writable($path)) {

    $errors[] = "Aviso: no se pudo escribir app.json (no writable): $path";

    return;

  }

  if (!file_exists($path) && !is_writable($dir)) {

    $errors[] = "Aviso: no se pudo crear app.json (carpeta no writable): $dir";

    return;

  }



  @file_put_contents($path, $json);

}



function pb_write_app_json(string $appsPath, array $m, array &$errors): void

{

  $appDir = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . (string) $m['source_app'];

  if (!is_dir($appDir)) {

    return;

  }



  $path = $appDir . DIRECTORY_SEPARATOR . 'app.json';

  pb_write_app_json_file($path, $m, $errors);

}



function pb_find_local_plugin_dir(string $publicRoot, array $m): ?string

{

  $pluginsRoot = rtrim($publicRoot, "/\\") . DIRECTORY_SEPARATOR . 'plugins';

  if (!is_dir($pluginsRoot)) {

    return null;

  }



  $candidates = array_values(array_unique(array_filter([

    (string) ($m['plugin_id'] ?? ''),

    (string) ($m['source_app'] ?? ''),

    (string) ($m['app_folder'] ?? ''),

  ])));



  foreach ($candidates as $candidate) {

    $dir = $pluginsRoot . DIRECTORY_SEPARATOR . $candidate;

    if (is_dir($dir) && is_file($dir . DIRECTORY_SEPARATOR . 'plugin.json')) {

      return $dir;

    }

  }



  foreach ((array) scandir($pluginsRoot) as $item) {

    if ($item === '.' || $item === '..') {

      continue;

    }



    $dir = $pluginsRoot . DIRECTORY_SEPARATOR . $item;

    $manifestPath = $dir . DIRECTORY_SEPARATOR . 'plugin.json';

    if (!is_dir($dir) || !is_file($manifestPath)) {

      continue;

    }



    $raw = (string) @file_get_contents($manifestPath);

    $data = json_decode($raw, true);

    if (!is_array($data)) {

      continue;

    }



    $manifestId = (string) ($data['id'] ?? '');

    $manifestFolder = (string) ($data['app_folder'] ?? '');

    $manifestEntry = (string) ($data['entry'] ?? '');



    if (

      in_array($manifestId, $candidates, true)

      || in_array($manifestFolder, $candidates, true)

      || in_array(basename(dirname($manifestEntry)), $candidates, true)

    ) {

      return $dir;

    }

  }



  return null;

}



function pb_local_plugin_manifest_payload(string $pluginDir, array $m): array
{
  $current = [];

  $roles = json_decode((string) ($m['roles_json_str'] ?? '[]'), true);
  if (!is_array($roles)) {
    $roles = [];
  }
  $scheduledTasks = pb_normalize_scheduled_tasks((array) ($m['scheduled_tasks'] ?? []));
  $supportScriptErrors = [];
  $supportScripts = pb_normalize_support_scripts((array) ($m['support_scripts'] ?? []), $supportScriptErrors);
  $changelog = pb_changelog_normalize((array) ($m['changelog'] ?? []));

  $current['id'] = (string) $m['plugin_id'];
  $current['name'] = (string) $m['name'];

  $current['version'] = (string) $m['version'];

  $current['min_intranet'] = (string) ($current['min_intranet'] ?? '2026.02');

  $current['entry'] = 'apps/' . (string) $m['app_folder'] . '/index.php';

  $current['app_folder'] = (string) $m['app_folder'];

  $current['description'] = (string) ($m['description'] ?? '');

  $current['author'] = (string) ($current['author'] ?? 'CSIF');

  $current['app_id'] = (string) $m['plugin_id'];

  $current['url'] = 'apps/' . (string) $m['app_folder'] . '/index.php';

  $current['icon'] = (string) ($m['icon'] ?? ($current['icon'] ?? 'box'));

  $current['roles_json'] = $roles;

  $current['new_tab'] = (int) ($m['new_tab'] ?? 0);

  $current['default_group_name'] = (string) ($m['default_group_name'] ?? 'Mis apps');
  $current['sql_scripts'] = array_values(array_filter($m['sql_scripts'] ?? [], static fn($item) => is_array($item)));
  $current['scheduled_tasks'] = $scheduledTasks;
  $current['support_scripts'] = $supportScripts;
  $current['changelog'] = $changelog;
  $current['app'] = [
    'app_id' => (string) $m['plugin_id'],

    'folder' => (string) $m['app_folder'],

    'url' => 'apps/' . (string) $m['app_folder'] . '/index.php',

    'default_group_name' => (string) ($m['default_group_name'] ?? 'Mis apps'),

  ];



  return $current;

}



function pb_write_local_plugin_json(string $pluginDir, array $m, array &$errors): void

{

  $path = $pluginDir . DIRECTORY_SEPARATOR . 'plugin.json';

  $data = pb_local_plugin_manifest_payload($pluginDir, $m);

  $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

  if ($json === false) {

    $errors[] = "Aviso: no se pudo generar plugin.json local para $pluginDir";

    return;

  }



  if (@file_put_contents($path, $json) === false) {

    $errors[] = "Aviso: no se pudo escribir plugin.json local: $path";

  }

}



function pb_write_local_plugin_readme(string $pluginDir, array $m, array &$errors): void
{
  $path = $pluginDir . DIRECTORY_SEPARATOR . 'README.md';
  $content = pb_readme_content($m);
  if (@file_put_contents($path, $content) === false) {
    $errors[] = "Aviso: no se pudo escribir README local: $path";
  }
}


function pb_write_local_plugin_installers(string $pluginDir, array $meta, string $templateInstaller, string $templateUninstaller, array &$errors): void
{
  $installDir = $pluginDir . DIRECTORY_SEPARATOR . 'install';

  @mkdir($installDir, 0775, true);



  $installerTpl = (string) @file_get_contents($templateInstaller);

  $uninstallerTpl = (string) @file_get_contents($templateUninstaller);

  if ($installerTpl === '' || $uninstallerTpl === '') {

    $errors[] = "Aviso: no se pudieron leer las plantillas installer/uninstaller para sincronizar $pluginDir";

    return;

  }



  $metaJson = json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);

  if ($metaJson === false) {

    $errors[] = "Aviso: no se pudo generar la metadata installer para $pluginDir";

    return;

  }



  $installerOut = str_replace('__PLUGIN_META_JSON__', $metaJson, $installerTpl);

  if (strpos($uninstallerTpl, '__PLUGIN_META_JSON__') !== false) {

    $uninstallerOut = str_replace('__PLUGIN_META_JSON__', $metaJson, $uninstallerTpl);

  } else {

    $uninstallerOut = str_replace(

      ['__PLUGIN_ID__', '__APP_FOLDER__'],

      [(string) $meta['app_id'], (string) $meta['app_folder']],

      $uninstallerTpl

    );

  }



  foreach ([

    'install.php' => $installerOut,

    'installer.php' => $installerOut,

    'uninstall.php' => $uninstallerOut,

    'uninstaller.php' => $uninstallerOut,

  ] as $file => $content) {

    $path = $installDir . DIRECTORY_SEPARATOR . $file;

    if (@file_put_contents($path, $content) === false) {

      $errors[] = "Aviso: no se pudo escribir installer local: $path";

    }

  }

}



function pb_sync_local_plugin_source(string $publicRoot, array $m, string $templateInstaller, string $templateUninstaller, array &$errors): bool
{
  $initialErrorCount = count($errors);

  $pluginDir = pb_find_local_plugin_dir($publicRoot, $m);

  if ($pluginDir === null) {

    $errors[] = 'Aviso: no se encontro el plugin local para sincronizar la version publicada.';

    return false;

  }



  pb_write_local_plugin_json($pluginDir, $m, $errors);
  pb_write_local_plugin_readme($pluginDir, $m, $errors);
  pb_copy_support_scripts_to_package($publicRoot, $pluginDir, (array) ($m['support_scripts'] ?? []), $errors);

  $pluginAppDir = $pluginDir . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR . (string) $m['app_folder'];
  if (is_dir($pluginAppDir)) {

    pb_write_app_json_file($pluginAppDir . DIRECTORY_SEPARATOR . 'app.json', $m, $errors);

  }



  $roles = json_decode((string) ($m['roles_json_str'] ?? '[]'), true);

  if (!is_array($roles)) {

    $roles = [];

  }



  $meta = [
    'id' => (string) $m['plugin_id'],
    'app_id' => (string) $m['plugin_id'],
    'name' => (string) $m['name'],
    'version' => (string) $m['version'],

    'url' => 'apps/' . (string) $m['app_folder'] . '/index.php',

    'description' => (string) ($m['description'] ?? ''),

    'icon' => (string) ($m['icon'] ?? 'box'),

    'image' => null,

    'new_tab' => (int) ($m['new_tab'] ?? 0),

    'roles_json' => $roles,

    'default_group_name' => (string) ($m['default_group_name'] ?? 'Mis apps'),
    'app_folder' => (string) $m['app_folder'],
    'sql_scripts' => array_values(array_filter($m['sql_scripts'] ?? [], static fn($item) => is_array($item))),
    'scheduled_tasks' => pb_normalize_scheduled_tasks((array) ($m['scheduled_tasks'] ?? [])),
    'support_scripts' => pb_normalize_support_scripts((array) ($m['support_scripts'] ?? [])),
    'changelog' => pb_changelog_normalize((array) ($m['changelog'] ?? [])),
    'app' => [
      'app_id' => (string) $m['plugin_id'],

      'folder' => (string) $m['app_folder'],

      'url' => 'apps/' . (string) $m['app_folder'] . '/index.php',

      'default_group_name' => (string) ($m['default_group_name'] ?? 'Mis apps'),

    ],

  ];

  pb_write_local_plugin_installers($pluginDir, $meta, $templateInstaller, $templateUninstaller, $errors);
  return count($errors) === $initialErrorCount;
}

function pb_sync_installed_plugin_registry(PDO $pdo, array $m, ?array $publishResult, string $zipFile, array &$errors): bool
{
  $pluginId = trim((string) ($m['plugin_id'] ?? ''));
  if ($pluginId === '') {
    return false;
  }

  try {
    $stmt = $pdo->prepare('SELECT enabled FROM intranet_plugins WHERE id=? LIMIT 1');
    $stmt->execute([$pluginId]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!is_array($row)) {
      return false;
    }

    $sourceUrl = '';
    if (is_array($publishResult) && !empty($publishResult['zip'])) {
      $sourceUrl = plugin_store_join_url(plugin_store_base_url(), (string) $publishResult['zip']);
    }
    if ($sourceUrl === '') {
      $sourceUrl = plugin_store_join_url(plugin_store_base_url(), 'download.php?id=' . rawurlencode($pluginId));
    }

    $sha256 = '';
    if (is_array($publishResult) && !empty($publishResult['sha256'])) {
      $sha256 = (string) $publishResult['sha256'];
    }
    if ($sha256 === '' && is_file($zipFile)) {
      $sha256 = (string) hash_file('sha256', $zipFile);
    }

    plugin_upsert_installed(
      $pdo,
      $pluginId,
      (string) ($m['name'] ?? $pluginId),
      (string) ($m['version'] ?? ''),
      (int) ($row['enabled'] ?? 1),
      $sourceUrl,
      $sha256
    );
    return true;
  } catch (Throwable $e) {
    $errors[] = 'Aviso: no se pudo sincronizar el registro local del plugin publicado: ' . $e->getMessage();
    return false;
  }
}

function pb_json_canonicalize($value)
{
  if (is_array($value)) {

    $isList = array_keys($value) === range(0, count($value) - 1);

    if ($isList) {

      foreach ($value as $idx => $item) {

        $value[$idx] = pb_json_canonicalize($item);

      }

      return $value;

    }



    ksort($value, SORT_STRING);

    foreach ($value as $key => $item) {

      $value[$key] = pb_json_canonicalize($item);

    }

  }



  return $value;

}



function pb_package_normalize_content(string $relativePath, string $content): ?string
{
  $relativePath = str_replace('\\', '/', ltrim($relativePath, '/'));

  if (basename($relativePath) === '.htaccess') {
    return null;
  }

  if ($relativePath === 'plugin.json') {
    $data = json_decode($content, true);

    if (is_array($data)) {

      unset($data['version'], $data['published_at']);

      $data = pb_json_canonicalize($data);

      return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    }

  }



  if (preg_match('~^apps/[^/]+/app\.json$~', $relativePath)) {

    $data = json_decode($content, true);

    if (is_array($data)) {

      unset($data['version'], $data['updated_at']);

      $data = pb_json_canonicalize($data);

      return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    }

  }



  if ($relativePath === 'README.md') {

    $content = preg_replace('/\(\d+\.\d+\.\d+\)/', '(__VERSION__)', $content);

    return (string) $content;

  }



  if (preg_match('~^install/(?:install|installer|uninstall|uninstaller)\.php$~', $relativePath)) {

    $content = preg_replace('/("version"\s*:\s*")([^"]+)(")/', '$1__VERSION__$3', $content);

    return (string) $content;

  }



  return $content;
}

function pb_source_app_needs_plugin_conversion(string $appsPath, string $sourceApp): bool
{
  if ($sourceApp === '') {
    return false;
  }

  $path = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . $sourceApp;
  return is_dir($path) && !is_link($path);
}

function pb_stage_existing_app_dir(string $appsPath, string $sourceApp, array &$errors): ?array
{
  $sourcePath = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . $sourceApp;
  if (!is_dir($sourcePath) || is_link($sourcePath)) {
    return null;
  }

  $backupPath = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . '__pb_backup_' . $sourceApp . '_' . bin2hex(random_bytes(6));
  if (!@rename($sourcePath, $backupPath)) {
    $errors[] = 'No se pudo apartar temporalmente la app original para convertirla en plugin: ' . $sourcePath;
    return null;
  }

  return [
    'source_path' => $sourcePath,
    'backup_path' => $backupPath,
  ];
}

function pb_restore_staged_app_dir(?array $swap, array &$errors): void
{
  if (!is_array($swap)) {
    return;
  }

  $sourcePath = (string) ($swap['source_path'] ?? '');
  $backupPath = (string) ($swap['backup_path'] ?? '');
  if ($sourcePath === '' || $backupPath === '' || !is_dir($backupPath)) {
    return;
  }

  if (is_link($sourcePath)) {
    @unlink($sourcePath);
  } elseif (file_exists($sourcePath)) {
    plugin_rrmdir($sourcePath);
  }

  if (!@rename($backupPath, $sourcePath)) {
    $errors[] = 'No se pudo restaurar la app original tras un fallo de conversion: ' . $sourcePath;
  }
}

function pb_release_notes_to_list(string $raw): array
{
  $raw = str_replace(["\r\n", "\r"], "\n", trim($raw));
  if ($raw === '') {
    return [];
  }

  $out = [];
  foreach (explode("\n", $raw) as $line) {
    $line = trim((string) $line);
    $line = preg_replace('/^\s*[-*•]+\s*/u', '', $line ?? '');
    $line = trim((string) $line);
    if ($line === '') {
      continue;
    }
    $out[] = $line;
  }

  return array_values(array_unique($out));
}

function pb_changelog_normalize(array $entries): array
{
  $out = [];
  foreach ($entries as $entry) {
    if (!is_array($entry)) {
      continue;
    }

    $version = trim((string) ($entry['version'] ?? ''));
    $changes = $entry['changes'] ?? [];
    if (!is_array($changes)) {
      $changes = [];
    }

    $cleanChanges = [];
    foreach ($changes as $change) {
      $change = trim((string) $change);
      if ($change !== '') {
        $cleanChanges[] = $change;
      }
    }

    if ($version === '' || empty($cleanChanges)) {
      continue;
    }

    $out[] = [
      'version' => $version,
      'date' => trim((string) ($entry['date'] ?? '')),
      'changes' => array_values(array_unique($cleanChanges)),
    ];
  }

  usort($out, static function (array $a, array $b): int {
    return version_compare((string) ($b['version'] ?? '0.0.0'), (string) ($a['version'] ?? '0.0.0'));
  });

  return $out;
}

function pb_changelog_merge(array $existing, string $version, array $changes): array
{
  $version = trim($version);
  $changes = array_values(array_unique(array_filter(array_map(static function ($item): string {
    return trim((string) $item);
  }, $changes), static fn($item) => $item !== '')));

  if ($version === '' || empty($changes)) {
    return pb_changelog_normalize($existing);
  }

  $normalized = pb_changelog_normalize($existing);
  $merged = [];
  $replaced = false;
  foreach ($normalized as $entry) {
    if (($entry['version'] ?? '') === $version) {
      $entry['date'] = date('c');
      $entry['changes'] = $changes;
      $replaced = true;
    }
    $merged[] = $entry;
  }

  if (!$replaced) {
    $merged[] = [
      'version' => $version,
      'date' => date('c'),
      'changes' => $changes,
    ];
  }

  return pb_changelog_normalize($merged);
}

function pb_discard_staged_app_dir(?array $swap): void
{
  if (!is_array($swap)) {
    return;
  }

  $backupPath = (string) ($swap['backup_path'] ?? '');
  if ($backupPath !== '' && is_dir($backupPath)) {
    plugin_rrmdir($backupPath);
  }
}

function pb_local_admin_db_defaults(): array
{
  return [
    'host' => defined('DB_HOST') ? (string) DB_HOST : 'localhost',
    'port' => defined('DB_PORT') ? (int) DB_PORT : 3306,
    'user' => '',
    'pass' => '',
  ];
}

function pb_local_admin_pdo_connect(array $adminDb): PDO
{
  $host = trim((string) ($adminDb['host'] ?? (defined('DB_HOST') ? DB_HOST : 'localhost')));
  $port = (int) ($adminDb['port'] ?? (defined('DB_PORT') ? DB_PORT : 3306));
  $user = trim((string) ($adminDb['user'] ?? ''));
  $pass = (string) ($adminDb['pass'] ?? '');

  if ($user === '') {
    throw new RuntimeException('Falta el usuario administrador de MySQL para ejecutar SQL con privilegios elevados.');
  }

  $dsn = "mysql:host={$host};port={$port};charset=utf8mb4";
  return new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  ]);
}

function pb_local_sql_file_payload(string $path, string $stage = 'install', string $mode = 'auto'): ?array
{
  if (!is_file($path)) {
    return null;
  }

  return [
    'file' => basename($path),
    'path' => $path,
    'exists' => true,
    'order' => 0,
    'stage' => $stage,
    'mode' => $mode,
    'sql' => (string) file_get_contents($path),
  ];
}

function pb_local_split_sql_statements(string $sql): array
{
  $statements = [];
  $buffer = '';
  $length = strlen($sql);
  $inSingle = false;
  $inDouble = false;
  $inBacktick = false;
  $inLineComment = false;
  $inBlockComment = false;

  for ($i = 0; $i < $length; $i++) {
    $char = $sql[$i];
    $next = ($i + 1 < $length) ? $sql[$i + 1] : '';

    if ($inLineComment) {
      $buffer .= $char;
      if ($char === "\n") {
        $inLineComment = false;
      }
      continue;
    }

    if ($inBlockComment) {
      $buffer .= $char;
      if ($char === '*' && $next === '/') {
        $buffer .= $next;
        $i++;
        $inBlockComment = false;
      }
      continue;
    }

    if (!$inSingle && !$inDouble && !$inBacktick) {
      if ($char === '-' && $next === '-' && (($i + 2 >= $length) || ctype_space($sql[$i + 2]))) {
        $buffer .= $char . $next;
        $i++;
        $inLineComment = true;
        continue;
      }
      if ($char === '#') {
        $buffer .= $char;
        $inLineComment = true;
        continue;
      }
      if ($char === '/' && $next === '*') {
        $buffer .= $char . $next;
        $i++;
        $inBlockComment = true;
        continue;
      }
    }

    if ($char === "'" && !$inDouble && !$inBacktick) {
      $escaped = ($i > 0 && $sql[$i - 1] === '\\');
      if (!$escaped) {
        $inSingle = !$inSingle;
      }
      $buffer .= $char;
      continue;
    }

    if ($char === '"' && !$inSingle && !$inBacktick) {
      $escaped = ($i > 0 && $sql[$i - 1] === '\\');
      if (!$escaped) {
        $inDouble = !$inDouble;
      }
      $buffer .= $char;
      continue;
    }

    if ($char === '`' && !$inSingle && !$inDouble) {
      $inBacktick = !$inBacktick;
      $buffer .= $char;
      continue;
    }

    if ($char === ';' && !$inSingle && !$inDouble && !$inBacktick) {
      $statement = trim($buffer);
      if ($statement !== '') {
        $statements[] = $statement;
      }
      $buffer = '';
      continue;
    }

    $buffer .= $char;
  }

  $tail = trim($buffer);
  if ($tail !== '') {
    $statements[] = $tail;
  }

  return $statements;
}

function pb_local_exec_sql_payload(PDO $pdo, array $payload, string $connectionLabel): array
{
  $count = 0;
  foreach (pb_local_split_sql_statements((string) ($payload['sql'] ?? '')) as $statement) {
    $trimmed = trim($statement);
    if ($trimmed === '' || preg_match('/^(--|#)/', $trimmed)) {
      continue;
    }
    $pdo->exec($statement);
    $count++;
  }

  $payload['statement_count'] = $count;
  $payload['connection'] = $connectionLabel;
  return $payload;
}

function pb_local_merge_sql_results(array $base, array $extra): array
{
  $merged = [
    'ok' => !empty($base['ok']) && !empty($extra['ok']),
    'executed' => array_merge($base['executed'] ?? [], $extra['executed'] ?? []),
    'manual' => array_merge($base['manual'] ?? [], $extra['manual'] ?? []),
    'errors' => array_merge($base['errors'] ?? [], $extra['errors'] ?? []),
    'connection' => (string) ($extra['connection'] ?? ($base['connection'] ?? '')),
  ];

  if ($merged['connection'] === '') {
    $merged['connection'] = (string) ($base['connection'] ?? '');
  }

  return $merged;
}

function pb_local_move_contents_up(string $fromDir, string $toDir): void
{
  if ($fromDir === $toDir) {
    return;
  }

  $items = @scandir($fromDir);
  if (!is_array($items)) {
    return;
  }

  foreach ($items as $it) {
    if ($it === '.' || $it === '..') {
      continue;
    }
    @rename($fromDir . DIRECTORY_SEPARATOR . $it, $toDir . DIRECTORY_SEPARATOR . $it);
  }

  @rmdir($fromDir);
}

function pb_local_find_root_dir(string $destDir): string
{
  if (is_file($destDir . '/plugin.json')) {
    return $destDir;
  }

  $items = @scandir($destDir);
  if (!is_array($items)) {
    return $destDir;
  }

  $dirs = [];
  foreach ($items as $it) {
    if ($it === '.' || $it === '..') {
      continue;
    }
    $p = $destDir . DIRECTORY_SEPARATOR . $it;
    if (is_dir($p)) {
      $dirs[] = $p;
    }
  }

  if (count($dirs) === 1 && is_file($dirs[0] . '/plugin.json')) {
    return $dirs[0];
  }

  return $destDir;
}

function pb_install_local_plugin_zip(PDO $pdo, string $zipFile, string $publicRoot, array $options, array &$errors): ?array
{
  $pluginId = trim((string) ($options['plugin_id'] ?? ''));
  if ($pluginId === '') {
    $errors[] = 'No se puede instalar localmente el plugin: falta plugin_id.';
    return null;
  }
  if (!is_file($zipFile)) {
    $errors[] = 'No se puede instalar localmente el plugin: no existe el ZIP generado.';
    return null;
  }

  $pluginsDir = realpath($publicRoot . '/plugins');
  if ($pluginsDir === false) {
    $pluginsDir = $publicRoot . '/plugins';
    @mkdir($pluginsDir, 0775, true);
  }

  $dest = plugin_safe_join($pluginsDir, $pluginId);
  if (is_dir($dest)) {
    plugin_rrmdir($dest);
  }
  if (!@mkdir($dest, 0775, true) && !is_dir($dest)) {
    $errors[] = 'No se pudo crear el directorio local del plugin: ' . $dest;
    return null;
  }

  if (!plugin_extract_zip($zipFile, $dest, $errors)) {
    return null;
  }

  $root = pb_local_find_root_dir($dest);
  if ($root !== $dest) {
    pb_local_move_contents_up($root, $dest);
  }

  $manifest = plugin_read_manifest($dest, $errors);
  if (!$manifest) {
    return null;
  }
  if ((string) ($manifest['id'] ?? '') !== $pluginId) {
    $errors[] = 'El plugin generado no coincide con el plugin_id esperado para la instalacion local.';
    return null;
  }

  $sqlStrategy = (string) ($options['sql_strategy'] ?? 'auto');
  if (!in_array($sqlStrategy, ['auto', 'show', 'admin', 'skip'], true)) {
    $sqlStrategy = 'auto';
  }

  $adminDb = is_array($options['admin_db'] ?? null) ? $options['admin_db'] : pb_local_admin_db_defaults();
  $sqlResult = [
    'ok' => true,
    'executed' => [],
    'manual' => [],
    'errors' => [],
    'connection' => '',
  ];

  $sqlInstall = $dest . '/install/install.sql';
  $legacySql = pb_local_sql_file_payload($sqlInstall, 'install', 'auto');
  if (is_array($legacySql)) {
    if ($sqlStrategy === 'show') {
      $legacySql['mode'] = 'manual';
      $sqlResult['manual'][] = $legacySql;
    } elseif ($sqlStrategy !== 'skip') {
      $sqlPdo = $pdo;
      $connectionLabel = 'usuario de aplicacion';
      if ($sqlStrategy === 'admin') {
        $sqlPdo = pb_local_admin_pdo_connect($adminDb);
        $connectionLabel = 'usuario administrador';
      }
      try {
        $sqlResult['executed'][] = pb_local_exec_sql_payload($sqlPdo, $legacySql, $connectionLabel);
        $sqlResult['connection'] = $connectionLabel;
      } catch (Throwable $e) {
        $sqlResult['errors'][] = 'Fallo al ejecutar install/install.sql: ' . $e->getMessage();
      }
    }
  }

  $phpInstall = $dest . '/install/install.php';
  if (!is_file($phpInstall)) {
    $phpInstall = $dest . '/install/installer.php';
  }

  $pluginInstallResult = null;
  if (is_file($phpInstall)) {
    try {
      $pdo_for_plugin = $pdo;
      $PLUGIN_INSTALL_CTX = [
        'public_root' => $publicRoot,
        'plugin_root' => $dest,
        'plugin_id' => $pluginId,
        'group_name' => (string) ($options['group_name'] ?? ($manifest['default_group_name'] ?? ($manifest['app']['default_group_name'] ?? 'Mis apps'))),
        'app_folder' => (string) ($manifest['app_folder'] ?? ($manifest['app']['folder'] ?? '')),
        'version' => (string) ($manifest['version'] ?? ''),
        'source_url' => (string) ($options['source_url'] ?? ''),
        'sha256' => (string) ($options['sha256'] ?? hash_file('sha256', $zipFile)),
        'sql_strategy' => $sqlStrategy,
        'admin_db' => $adminDb,
      ];
      $PLUGIN_INSTALL_RESULT = null;
      require $phpInstall;
      $pluginInstallResult = is_array($PLUGIN_INSTALL_RESULT ?? null) ? $PLUGIN_INSTALL_RESULT : null;
      unset($PLUGIN_INSTALL_RESULT, $PLUGIN_INSTALL_CTX, $pdo_for_plugin);
    } catch (Throwable $e) {
      $errors[] = 'Fallo en el instalador PHP del plugin durante la conversion local: ' . $e->getMessage();
      return null;
    }
  }

  if (is_array($pluginInstallResult) && isset($pluginInstallResult['sql']) && is_array($pluginInstallResult['sql'])) {
    $sqlResult = pb_local_merge_sql_results($sqlResult, $pluginInstallResult['sql']);
  }

  $sqlOnly = false;
  $installOk = empty($sqlResult['errors']);
  $message = 'Plugin instalado localmente correctamente.';
  if (is_array($pluginInstallResult)) {
    $installOk = $installOk && !empty($pluginInstallResult['ok']);
    $sqlOnly = !empty($pluginInstallResult['sql_only']);
    $message = (string) ($pluginInstallResult['msg'] ?? $message);
  }

  if ($sqlStrategy === 'show' && (!empty($sqlResult['manual']) || is_array($pluginInstallResult))) {
    $sqlOnly = true;
    if ($message === 'Plugin instalado localmente correctamente.') {
      $message = 'SQL preparados para ejecucion manual.';
    }
  }

  if ($installOk && !$sqlOnly) {
    plugin_upsert_installed(
      $pdo,
      (string) $manifest['id'],
      (string) ($manifest['name'] ?? $pluginId),
      (string) ($manifest['version'] ?? '1.0.0'),
      1,
      (string) ($options['source_url'] ?? ''),
      (string) ($options['sha256'] ?? hash_file('sha256', $zipFile))
    );
    plugin_app_register($pdo, $manifest, 1);
  }

  return [
    'ok' => $installOk,
    'sql_only' => $sqlOnly,
    'msg' => $message,
    'plugin_id' => $pluginId,
    'plugin_name' => (string) ($manifest['name'] ?? $pluginId),
    'manifest' => $manifest,
    'sql' => $sqlResult,
  ];
}


function pb_package_root_dir(string $dir): string

{

  $dir = rtrim($dir, '/\\');

  if (is_file($dir . '/plugin.json')) {

    return $dir;

  }



  $items = @scandir($dir) ?: [];

  $dirs = [];

  foreach ($items as $item) {

    if ($item === '.' || $item === '..') {

      continue;

    }

    $full = $dir . '/' . $item;

    if (is_dir($full)) {

      $dirs[] = $full;

    }

  }



  if (count($dirs) === 1 && is_file($dirs[0] . '/plugin.json')) {

    return $dirs[0];

  }



  return $dir;

}



function pb_package_fingerprint(string $rootDir, array &$errors): ?array

{

  if (!is_dir($rootDir)) {

    $errors[] = 'No existe el directorio del paquete a comparar.';

    return null;

  }



  $rootDir = pb_package_root_dir($rootDir);

  if (!is_file($rootDir . '/plugin.json')) {

    $errors[] = 'El paquete a comparar no contiene plugin.json.';

    return null;

  }



  $files = [];

  $rii = new RecursiveIteratorIterator(

    new RecursiveDirectoryIterator($rootDir, FilesystemIterator::SKIP_DOTS)

  );



  foreach ($rii as $file) {

    if (!$file->isFile()) {

      continue;

    }



    $path = $file->getPathname();

    $rel = str_replace('\\', '/', substr($path, strlen($rootDir) + 1));

    $raw = @file_get_contents($path);

    if ($raw === false) {

      $errors[] = 'No se pudo leer para comparar: ' . $rel;

      return null;

    }



    $normalized = pb_package_normalize_content($rel, $raw);

    if ($normalized === null) {

      continue;

    }



    $files[$rel] = hash('sha256', $normalized);

  }



  ksort($files, SORT_STRING);



  return [

    'hash' => hash('sha256', json_encode($files, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)),

    'files' => $files,

  ];

}



function pb_remote_store_plugin(string $pluginId, array &$errors): ?array

{

  $store = plugin_store_fetch_index($errors);

  if (!is_array($store)) {

    return null;

  }



  $list = $store['plugins'] ?? $store;

  if (!is_array($list)) {

    return null;

  }



  foreach ($list as $item) {

    if (is_array($item) && (string) ($item['id'] ?? '') === $pluginId) {

      return $item;

    }

  }



  return null;

}



function pb_store_same_package(string $pluginId, string $localRoot, array &$errors): ?array

{

  $remote = pb_remote_store_plugin($pluginId, $errors);

  if (!is_array($remote)) {

    return null;

  }



  $zipUrl = trim((string) ($remote['zip'] ?? ''));

  if ($zipUrl === '') {

    return null;

  }



  $zipUrl = plugin_store_join_url(plugin_store_base_url(), $zipUrl);

  $sha256 = (string) ($remote['sha256'] ?? '');

  $tmpZip = plugin_download_to_tmp($zipUrl, $sha256, $errors, true);

  if (!$tmpZip) {

    return null;

  }



  $tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pb_remote_' . bin2hex(random_bytes(6));



  try {

    if (!plugin_extract_zip($tmpZip, $tmpDir, $errors)) {

      return null;

    }



    $localFp = pb_package_fingerprint($localRoot, $errors);

    $remoteFp = pb_package_fingerprint($tmpDir, $errors);

    if (!is_array($localFp) || !is_array($remoteFp)) {

      return null;

    }



    return [

      'same' => hash_equals((string) $localFp['hash'], (string) $remoteFp['hash']),

      'remote' => $remote,

      'local_hash' => (string) $localFp['hash'],

      'remote_hash' => (string) $remoteFp['hash'],

    ];

  } finally {

    @unlink($tmpZip);

    plugin_rrmdir($tmpDir);

  }

}



function pb_read_app_json(string $appsPath, string $sourceApp): ?array
{
  $appDir = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . $sourceApp;

  $path = $appDir . DIRECTORY_SEPARATOR . 'app.json';

  if (!is_file($path)) {

    return null;

  }



  $raw = (string) @file_get_contents($path);

  if ($raw === '') {

    return null;

  }



  $data = json_decode($raw, true);
  return is_array($data) ? $data : null;
}

function pb_read_local_plugin_manifest(string $publicRoot, array $m): ?array
{
  $pluginDir = pb_find_local_plugin_dir($publicRoot, $m);
  if ($pluginDir === null) {
    return null;
  }

  $path = $pluginDir . DIRECTORY_SEPARATOR . 'plugin.json';
  if (!is_file($path)) {
    return null;
  }

  $raw = (string) @file_get_contents($path);
  if ($raw === '') {
    return null;
  }

  $data = json_decode($raw, true);
  return is_array($data) ? $data : null;
}

function pb_latest_local_plugin_php(string $pluginDir): array
{
  $out = [
    'count' => 0,
    'mtime' => 0,
    'path' => '',
  ];

  if (!is_dir($pluginDir)) {
    return $out;
  }

  $base = rtrim(str_replace('\\', '/', $pluginDir), '/');
  $it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($pluginDir, FilesystemIterator::SKIP_DOTS)
  );

  foreach ($it as $file) {
    if (!$file->isFile()) {
      continue;
    }

    $path = str_replace('\\', '/', $file->getPathname());
    $relPath = ltrim(substr($path, strlen($base)), '/');
    if (preg_match('~(^|/)(common|install|instalar|installation)(/|$)~i', $relPath)) {
      continue;
    }

    if (strtolower($file->getExtension()) !== 'php') {
      continue;
    }

    $out['count']++;
    $mtime = (int) (@filemtime($file->getPathname()) ?: 0);
    if ($mtime > (int) $out['mtime']) {
      $out['mtime'] = $mtime;
      $out['path'] = $relPath;
    }
  }

  return $out;
}

function pb_format_local_datetime(int $timestamp): string
{
  if ($timestamp <= 0) {
    return 'No disponible';
  }
  return date('Y-m-d H:i:s', $timestamp);
}

function pb_format_store_datetime(string $value): string
{
  $value = trim($value);
  if ($value === '') {
    return 'No publicado';
  }

  try {
    $dt = new DateTimeImmutable($value);
    $tzName = date_default_timezone_get() ?: 'Europe/Madrid';
    $dt = $dt->setTimezone(new DateTimeZone($tzName));
    return $dt->format('Y-m-d H:i:s');
  } catch (Throwable $e) {
    return $value;
  }
}

function pb_list_sql_files(string $appsPath, string $sourceApp): array
{
  $appDir = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . $sourceApp;

  if ($sourceApp === '' || !is_dir($appDir)) {

    return [];

  }



  $files = [];

  $rii = new RecursiveIteratorIterator(

    new RecursiveDirectoryIterator($appDir, FilesystemIterator::SKIP_DOTS)

  );



  foreach ($rii as $file) {

    if (!$file->isFile()) {

      continue;

    }



    $path = $file->getPathname();

    $rel = substr($path, strlen($appDir) + 1);

    $rel = str_replace('\\', '/', $rel);



    if (preg_match('~(^|/)(vendor|node_modules|\.git|\.idea|\.vscode)(/|$)~', $rel)) {

      continue;

    }

    if (strtolower((string) $file->getExtension()) !== 'sql') {

      continue;

    }



    $files[] = $rel;

  }



  sort($files, SORT_NATURAL | SORT_FLAG_CASE);

  return $files;

}



function pb_strip_table_detection_comments(string $content): string
{
  if ($content === '') {
    return '';
  }

  $content = preg_replace('~/\*.*?\*/~s', ' ', $content) ?? $content;
  $content = preg_replace('~(^|[\r\n])\s*//[^\r\n]*~', '$1 ', $content) ?? $content;
  $content = preg_replace('~(^|[\r\n])\s*(?:#|--)[^\r\n]*~', '$1 ', $content) ?? $content;

  return $content;
}

function pb_extract_table_operations_from_content(string $content): array
{
  if ($content === '') {
    return [];
  }

  $content = pb_strip_table_detection_comments($content);
  if ($content === '') {
    return [];
  }

  $patterns = [
    'create' => '~\bCREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'alter' => '~\bALTER\s+TABLE\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'drop' => '~\bDROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'truncate' => '~\bTRUNCATE\s+TABLE\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'show_create' => '~\bSHOW\s+CREATE\s+TABLE\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'show_columns' => '~\bSHOW\s+COLUMNS\s+FROM\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'describe' => '~\bDESCRIBE\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'insert' => '~\bINSERT\s+INTO\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'replace' => '~\bREPLACE\s+INTO\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'update' => '~\bUPDATE\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?\s+SET\b~i',
    'delete' => '~\bDELETE\s+FROM\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'from' => '~\bFROM\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
    'join' => '~\bJOIN\s+(?:`?([A-Za-z_][A-Za-z0-9_]*)`?\.)?`?([A-Za-z_][A-Za-z0-9_]*)`?~i',
  ];

  $ignoredSchemas = [
    'information_schema' => true,
    'performance_schema' => true,
    'mysql' => true,
    'sys' => true,
  ];

  $ignoredTables = [
    'dual' => true,
    'tables' => true,
    'columns' => true,
    'views' => true,
    'triggers' => true,
    'routines' => true,
    'events' => true,
    'statistics' => true,
    'schemata' => true,
  ];

  $out = [];
  foreach ($patterns as $operation => $pattern) {
    if (!preg_match_all($pattern, $content, $matches)) {
      continue;
    }

    $schemas = (array) ($matches[1] ?? []);
    $tables = (array) ($matches[2] ?? []);
    $count = max(count($schemas), count($tables));

    for ($i = 0; $i < $count; $i++) {
      $schema = strtolower(trim((string) ($schemas[$i] ?? '')));
      $table = strtolower(trim((string) ($tables[$i] ?? '')));
      if ($table === '') {
        continue;
      }
      if ($schema !== '' && isset($ignoredSchemas[$schema])) {
        continue;
      }
      if (isset($ignoredSchemas[$table])) {
        continue;
      }
      if (isset($ignoredTables[$table])) {
        continue;
      }
      if (!isset($out[$table])) {
        $out[$table] = [
          'name' => $table,
          'operations' => [],
        ];
      }
      $out[$table]['operations'][$operation] = true;
    }
  }

  ksort($out, SORT_NATURAL | SORT_FLAG_CASE);
  foreach ($out as &$table) {
    $operations = array_keys((array) ($table['operations'] ?? []));
    sort($operations, SORT_NATURAL | SORT_FLAG_CASE);
    $table['operations'] = $operations;
  }
  unset($table);

  return $out;
}

function pb_extract_table_names_from_content(string $content): array
{
  $tables = array_keys(pb_extract_table_operations_from_content($content));
  sort($tables, SORT_NATURAL | SORT_FLAG_CASE);
  return $tables;
}


function pb_detect_plugin_tables(string $appsPath, string $sourceApp): array

{

  $appDir = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . $sourceApp;

  if ($sourceApp === '' || !is_dir($appDir)) {

    return [];

  }



  $out = [];

  $rii = new RecursiveIteratorIterator(

    new RecursiveDirectoryIterator($appDir, FilesystemIterator::SKIP_DOTS)

  );



  foreach ($rii as $file) {

    if (!$file->isFile()) {

      continue;

    }



    $path = $file->getPathname();

    $rel = substr($path, strlen($appDir) + 1);

    $rel = str_replace('\\', '/', $rel);

    $ext = strtolower((string) $file->getExtension());



    if (preg_match('~(^|/)(vendor|node_modules|\.git|\.idea|\.vscode)(/|$)~', $rel)) {

      continue;

    }

    if (!in_array($ext, ['php', 'sql', 'inc', 'phtml', 'tpl', 'js'], true)) {

      continue;

    }



    $raw = (string) @file_get_contents($path);

    if ($raw === '') {

      continue;

    }



    foreach (pb_extract_table_operations_from_content($raw) as $table => $ref) {
      if (!isset($out[$table])) {
        $out[$table] = [
          'name' => $table,
          'files' => [],
          'sources' => [],
          'operations' => [],
          'sql_plan' => false,
        ];
      }

      $out[$table]['files'][$rel] = true;
      $out[$table]['sources'][$ext === 'sql' ? 'sql' : 'source'] = true;

      $operations = array_values(array_filter(array_map(
        static fn($operation): string => trim((string) $operation),
        (array) ($ref['operations'] ?? [])
      )));
      foreach ($operations as $operation) {
        $out[$table]['operations'][$operation] = true;
      }
      if ($ext === 'sql' && array_intersect($operations, ['create', 'alter'])) {
        $out[$table]['sql_plan'] = true;
      }
    }
  }


  ksort($out, SORT_NATURAL | SORT_FLAG_CASE);

  foreach ($out as &$table) {

    $files = array_keys((array) ($table['files'] ?? []));

    sort($files, SORT_NATURAL | SORT_FLAG_CASE);

    $sources = array_keys((array) ($table['sources'] ?? []));
    sort($sources, SORT_NATURAL | SORT_FLAG_CASE);
    $operations = array_keys((array) ($table['operations'] ?? []));
    sort($operations, SORT_NATURAL | SORT_FLAG_CASE);
    $table['files'] = $files;
    $table['sources'] = $sources;
    $table['operations'] = $operations;
    $table['sql_plan'] = !empty($table['sql_plan']);
  }
  unset($table);



  return $out;

}



function pb_db_table_schemas(PDO $pdo, array $tableNames): array

{

  $tableNames = array_values(array_unique(array_filter(array_map(

    static fn($name): string => strtolower(trim((string) $name)),

    $tableNames

  ))));



  if (empty($tableNames)) {

    return [];

  }



  $out = [];

  foreach ($tableNames as $tableName) {

    $out[$tableName] = [

      'exists' => false,

      'engine' => '',

      'comment' => '',

      'columns' => [],

    ];

  }



  $placeholders = implode(',', array_fill(0, count($tableNames), '?'));



  $stTables = $pdo->prepare(

    "SELECT table_name, engine, table_comment

     FROM information_schema.tables

     WHERE table_schema = DATABASE()

       AND table_name IN ($placeholders)"

  );

  $stTables->execute($tableNames);

  while ($row = $stTables->fetch(PDO::FETCH_ASSOC)) {

    $tableName = strtolower((string) ($row['table_name'] ?? ''));

    if ($tableName === '' || !isset($out[$tableName])) {

      continue;

    }

    $out[$tableName]['exists'] = true;

    $out[$tableName]['engine'] = (string) ($row['engine'] ?? '');

    $out[$tableName]['comment'] = (string) ($row['table_comment'] ?? '');

  }



  $stCols = $pdo->prepare(

    "SELECT table_name, column_name, column_type, is_nullable, column_default, column_key, extra

     FROM information_schema.columns

     WHERE table_schema = DATABASE()

       AND table_name IN ($placeholders)

     ORDER BY table_name, ordinal_position"

  );

  $stCols->execute($tableNames);

  while ($row = $stCols->fetch(PDO::FETCH_ASSOC)) {

    $tableName = strtolower((string) ($row['table_name'] ?? ''));

    if ($tableName === '' || !isset($out[$tableName])) {

      continue;

    }

    $out[$tableName]['exists'] = true;

    $out[$tableName]['columns'][] = [

      'name' => (string) ($row['column_name'] ?? ''),

      'type' => (string) ($row['column_type'] ?? ''),

      'nullable' => ((string) ($row['is_nullable'] ?? '')) === 'YES' ? 'SI' : 'NO',

      'default' => $row['column_default'],

      'key' => (string) ($row['column_key'] ?? ''),

      'extra' => (string) ($row['extra'] ?? ''),

    ];

  }



  return $out;

}



function pb_sql_defaults_for_file(string $file): array

{

  $name = strtolower(basename($file));

  $order = 50;

  $stage = 'update';

  $mode = 'auto';

  $enabled = 1;



  if (strpos($name, '_schema_v2.sql') !== false) {

    $order = 10;

    $stage = 'install';

  } elseif (strpos($name, '_migrate') !== false) {

    $order = 20;

    $stage = 'migrate';

  } elseif (strpos($name, '_schema_update') !== false) {

    $order = 30;

    $stage = 'update';

  } elseif (strpos($name, '_schema_drop_legacy') !== false || strpos($name, 'drop_legacy') !== false) {

    $order = 90;

    $stage = 'cleanup';

    $mode = 'manual';

  } elseif (strpos($name, '_schema_drop') !== false) {

    $order = 90;

    $stage = 'cleanup';

    $mode = 'manual';

  }



  return [

    'file' => $file,

    'enabled' => $enabled,

    'order' => $order,

    'stage' => $stage,

    'mode' => $mode,

  ];

}



function pb_normalize_sql_scripts(array $detectedFiles, array $savedScripts = []): array

{

  $savedByFile = [];

  foreach ($savedScripts as $item) {

    if (!is_array($item) || empty($item['file'])) {

      continue;

    }

    $savedByFile[(string) $item['file']] = $item;

  }



  $out = [];

  foreach ($detectedFiles as $file) {

    $defaults = pb_sql_defaults_for_file((string) $file);

    $saved = $savedByFile[(string) $file] ?? [];



    $out[] = [

      'file' => (string) $file,

      'enabled' => isset($saved['enabled']) ? (int) (bool) $saved['enabled'] : (int) $defaults['enabled'],

      'order' => isset($saved['order']) ? (int) $saved['order'] : (int) $defaults['order'],

      'stage' => (string) ($saved['stage'] ?? $defaults['stage']),

      'mode' => (string) ($saved['mode'] ?? $defaults['mode']),

    ];

  }



  usort($out, static function (array $a, array $b): int {

    $cmp = ((int) $a['order']) <=> ((int) $b['order']);

    if ($cmp !== 0) {

      return $cmp;

    }

    return strcasecmp((string) $a['file'], (string) $b['file']);

  });



  return $out;

}



function pb_sql_scripts_from_post(array $detectedFiles, array $post): array
{
  $saved = [];
  $enabledMap = $post['sql_enabled'] ?? [];

  $orderMap = $post['sql_order'] ?? [];

  $stageMap = $post['sql_stage'] ?? [];

  $modeMap = $post['sql_mode'] ?? [];



  foreach ($detectedFiles as $file) {

    $file = (string) $file;

    $saved[] = [

      'file' => $file,

      'enabled' => isset($enabledMap[$file]) ? 1 : 0,

      'order' => isset($orderMap[$file]) ? (int) $orderMap[$file] : 50,

      'stage' => (string) ($stageMap[$file] ?? 'update'),

      'mode' => (string) ($modeMap[$file] ?? 'auto'),

    ];

  }


  return pb_normalize_sql_scripts($detectedFiles, $saved);
}

function pb_list_task_candidate_files(string $appsPath, string $sourceApp): array
{
  $appDir = rtrim($appsPath, "/\\") . DIRECTORY_SEPARATOR . $sourceApp;
  if ($sourceApp === '' || !is_dir($appDir)) {
    return [];
  }

  $files = [];
  $rii = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($appDir, FilesystemIterator::SKIP_DOTS)
  );

  foreach ($rii as $file) {
    if (!$file->isFile()) {
      continue;
    }

    $path = $file->getPathname();
    $rel = substr($path, strlen($appDir) + 1);
    $rel = str_replace('\\', '/', $rel);
    $ext = strtolower((string) $file->getExtension());

    if (preg_match('~(^|/)(vendor|node_modules|\.git|\.idea|\.vscode|uploads|backups|cache|tmp|temp|logs)(/|$)~', $rel)) {
      continue;
    }
    if (!in_array($ext, ['php', 'inc', 'sh', 'ps1', 'psm1', 'bat', 'cmd', 'py'], true)) {
      continue;
    }
    if (!preg_match('~(^|/)(cron|worker|workers|job|jobs|queue|task|tasks|schedule|scheduler|cli|bin|command)(/|[^/]*$)~i', $rel)) {
      continue;
    }

    $files[] = $rel;
  }

  sort($files, SORT_NATURAL | SORT_FLAG_CASE);
  return array_values(array_unique($files));
}

function pb_normalize_relative_script_path(string $path): string
{
  $path = trim(str_replace('\\', '/', $path));
  $path = preg_replace('~/+~', '/', $path);
  $path = ltrim((string) $path, '/');
  if ($path === '') {
    return '';
  }
  if (preg_match('~(^|/)\.\.(/|$)~', $path)) {
    return '';
  }
  return $path;
}

function pb_normalize_scheduled_tasks(array $tasks): array
{
  $out = [];
  foreach ($tasks as $task) {
    if (!is_array($task)) {
      continue;
    }

    $name = trim((string) ($task['name'] ?? ''));
    $script = pb_normalize_relative_script_path((string) ($task['script'] ?? ''));
    $schedule = trim((string) ($task['schedule'] ?? ''));
    $mode = strtolower(trim((string) ($task['mode'] ?? 'manual')));
    $phpBin = trim((string) ($task['php_bin'] ?? '/usr/bin/php'));
    $args = trim((string) ($task['args'] ?? ''));
    $managedPath = pb_normalize_relative_script_path((string) ($task['managed_path'] ?? ''));
    $description = trim((string) ($task['description'] ?? ''));
    $enabled = !empty($task['enabled']) ? 1 : 0;

    if ($name === '' && $script === '' && $schedule === '' && $description === '' && $args === '' && $managedPath === '') {
      continue;
    }

    if ($mode !== 'managed') {
      $mode = 'manual';
    }
    if ($phpBin === '') {
      $phpBin = '/usr/bin/php';
    }

    $taskIdSource = $name !== '' ? $name : ($script !== '' ? pathinfo($script, PATHINFO_FILENAME) : 'tarea');
    $taskId = slugify($taskIdSource);
    if ($taskId === '') {
      $taskId = 'tarea';
    }

    $out[] = [
      'enabled' => $enabled,
      'id' => $taskId,
      'name' => $name !== '' ? $name : ucfirst(str_replace(['-', '_'], ' ', $taskId)),
      'script' => $script,
      'schedule' => $schedule,
      'mode' => $mode,
      'php_bin' => $phpBin,
      'args' => $args,
      'managed_path' => $managedPath,
      'description' => $description,
    ];
  }

  return $out;
}

function pb_scheduled_tasks_from_post(array $post, array &$errors): array
{
  $enabledMap = $post['task_enabled'] ?? [];
  $nameMap = $post['task_name'] ?? [];
  $scriptMap = $post['task_script'] ?? [];
  $scheduleMap = $post['task_schedule'] ?? [];
  $modeMap = $post['task_mode'] ?? [];
  $phpBinMap = $post['task_php_bin'] ?? [];
  $argsMap = $post['task_args'] ?? [];
  $managedPathMap = $post['task_managed_path'] ?? [];
  $descriptionMap = $post['task_description'] ?? [];

  $max = max(
    is_array($enabledMap) ? count($enabledMap) : 0,
    is_array($nameMap) ? count($nameMap) : 0,
    is_array($scriptMap) ? count($scriptMap) : 0,
    is_array($scheduleMap) ? count($scheduleMap) : 0,
    is_array($modeMap) ? count($modeMap) : 0,
    is_array($phpBinMap) ? count($phpBinMap) : 0,
    is_array($argsMap) ? count($argsMap) : 0,
    is_array($managedPathMap) ? count($managedPathMap) : 0,
    is_array($descriptionMap) ? count($descriptionMap) : 0
  );

  $raw = [];
  for ($i = 0; $i < $max; $i++) {
    $row = [
      'enabled' => !empty($enabledMap[$i]) ? 1 : 0,
      'name' => trim((string) ($nameMap[$i] ?? '')),
      'script' => trim((string) ($scriptMap[$i] ?? '')),
      'schedule' => trim((string) ($scheduleMap[$i] ?? '')),
      'mode' => trim((string) ($modeMap[$i] ?? 'manual')),
      'php_bin' => trim((string) ($phpBinMap[$i] ?? '/usr/bin/php')),
      'args' => trim((string) ($argsMap[$i] ?? '')),
      'managed_path' => trim((string) ($managedPathMap[$i] ?? '')),
      'description' => trim((string) ($descriptionMap[$i] ?? '')),
    ];

    $hasData = false;
    foreach (['name', 'script', 'schedule', 'args', 'managed_path', 'description'] as $field) {
      if ($row[$field] !== '') {
        $hasData = true;
        break;
      }
    }
    if (!$hasData) {
      continue;
    }

    if ($row['script'] === '') {
      $errors[] = 'Cada tarea programada debe indicar un script relativo a la app.';
      continue;
    }
    if ($row['schedule'] === '') {
      $errors[] = 'Cada tarea programada debe indicar la expresion cron.';
      continue;
    }
    if (pb_normalize_relative_script_path($row['script']) === '') {
      $errors[] = 'Hay una tarea programada con script no valido. Usa rutas relativas dentro de la app.';
      continue;
    }
    if ($row['managed_path'] !== '' && pb_normalize_relative_script_path($row['managed_path']) === '') {
      $errors[] = 'Hay una tarea programada con ruta privada no valida.';
      continue;
    }

    $raw[] = $row;
  }

  return pb_normalize_scheduled_tasks($raw);
}

function pb_normalize_project_relative_path(string $path): string
{
  $path = trim(str_replace('\\', '/', $path));
  $path = preg_replace('~/+~', '/', $path);
  $path = ltrim((string) $path, '/');
  if ($path === '') {
    return '';
  }
  if (
    preg_match('~(^|/)\.\.(/|$)~', $path)
    || preg_match('~^[A-Za-z]:~', $path)
    || preg_match('~^(?:https?:)?//~i', $path)
    || preg_match('/[\x00-\x1F\x7F]/', $path)
  ) {
    return '';
  }
  return $path;
}

function pb_normalize_package_relative_path(string $path): string
{
  $path = pb_normalize_project_relative_path($path);
  if ($path === '') {
    return '';
  }
  if (preg_match('~^(?:plugin\.json|README\.md|apps/|install/)~i', $path)) {
    return '';
  }
  return $path;
}

function pb_support_script_default_package_path(string $sourcePath): string
{
  $sourcePath = pb_normalize_project_relative_path($sourcePath);
  $name = basename($sourcePath);
  $name = preg_replace('~[^A-Za-z0-9._-]+~', '-', $name);
  $name = trim((string) $name, '.-');
  return $name !== '' ? 'scripts/' . $name : '';
}

function pb_normalize_support_scripts(array $scripts, array &$errors = []): array
{
  $out = [];
  foreach ($scripts as $script) {
    if (!is_array($script)) {
      continue;
    }

    $sourcePath = pb_normalize_project_relative_path((string) ($script['source_path'] ?? ''));
    $packagePath = pb_normalize_package_relative_path((string) ($script['package_path'] ?? ''));
    $description = trim((string) ($script['description'] ?? ''));
    $instructions = trim((string) ($script['instructions'] ?? ''));

    if ($sourcePath === '' && $packagePath === '' && $description === '' && $instructions === '') {
      continue;
    }
    if ($sourcePath === '') {
      $errors[] = 'Cada script auxiliar debe indicar una ruta origen relativa al proyecto.';
      continue;
    }
    if ($packagePath === '') {
      $packagePath = pb_support_script_default_package_path($sourcePath);
    }
    if ($packagePath === '') {
      $errors[] = 'Hay un script auxiliar con ruta destino no valida.';
      continue;
    }

    $out[] = [
      'source_path' => $sourcePath,
      'package_path' => $packagePath,
      'description' => $description,
      'instructions' => $instructions,
    ];
  }

  $seen = [];
  $unique = [];
  foreach ($out as $script) {
    $key = strtolower((string) $script['package_path']);
    if (isset($seen[$key])) {
      $errors[] = 'Hay scripts auxiliares con la misma ruta destino: ' . (string) $script['package_path'];
      continue;
    }
    $seen[$key] = true;
    $unique[] = $script;
  }

  return $unique;
}

function pb_support_scripts_from_post(array $post, array &$errors): array
{
  $sourceMap = $post['support_source_path'] ?? [];
  $packageMap = $post['support_package_path'] ?? [];
  $descriptionMap = $post['support_description'] ?? [];
  $instructionsMap = $post['support_instructions'] ?? [];

  $max = max(
    is_array($sourceMap) ? count($sourceMap) : 0,
    is_array($packageMap) ? count($packageMap) : 0,
    is_array($descriptionMap) ? count($descriptionMap) : 0,
    is_array($instructionsMap) ? count($instructionsMap) : 0
  );

  $raw = [];
  for ($i = 0; $i < $max; $i++) {
    $raw[] = [
      'source_path' => trim((string) ($sourceMap[$i] ?? '')),
      'package_path' => trim((string) ($packageMap[$i] ?? '')),
      'description' => trim((string) ($descriptionMap[$i] ?? '')),
      'instructions' => trim((string) ($instructionsMap[$i] ?? '')),
    ];
  }

  return pb_normalize_support_scripts($raw, $errors);
}

function pb_support_script_candidate_allowed(string $rel, string $ext, bool $broadDir): bool
{
  $ext = strtolower($ext);
  if (in_array($ext, ['ps1', 'psm1', 'psd1', 'bat', 'cmd', 'sh', 'py', 'vbs', 'wsf', 'reg', 'sql'], true)) {
    return true;
  }

  if (in_array($ext, ['php', 'inc', 'js', 'mjs', 'ts'], true)) {
    if ($broadDir) {
      return true;
    }
    return (bool) preg_match('~(^|/|[-_])(cron|worker|job|task|cli|script|tool|migrat|sync|import|export|process|cleanup|setup|install|deploy|generate)([-_./]|$)~i', $rel);
  }

  return false;
}

function pb_list_support_script_candidates(string $publicRoot, string $sourceApp = ''): array
{
  $base = rtrim($publicRoot, "/\\");
  $dirs = [
    ['path' => 'scripts', 'broad' => true],
    ['path' => 'tools', 'broad' => true],
  ];
  $sourceApp = trim(str_replace('\\', '/', $sourceApp), '/');
  if ($sourceApp !== '' && !preg_match('~(^|/)\.\.(/|$)~', $sourceApp)) {
    $dirs[] = ['path' => 'apps/' . $sourceApp, 'broad' => false];
  }
  $out = [];

  foreach ($dirs as $entry) {
    $dir = (string) ($entry['path'] ?? '');
    $broadDir = !empty($entry['broad']);
    $fullDir = $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $dir);
    if (!is_dir($fullDir)) {
      continue;
    }

    $rii = new RecursiveIteratorIterator(
      new RecursiveDirectoryIterator($fullDir, FilesystemIterator::SKIP_DOTS)
    );
    foreach ($rii as $file) {
      if (!$file->isFile()) {
        continue;
      }
      $rel = str_replace('\\', '/', substr($file->getPathname(), strlen($base) + 1));
      if (preg_match('~(^|/)(vendor|node_modules|\.git|\.idea|\.vscode|uploads|backups|cache|tmp|temp|logs)(/|$)~i', $rel)) {
        continue;
      }
      $ext = strtolower((string) $file->getExtension());
      if (!pb_support_script_candidate_allowed($rel, $ext, $broadDir)) {
        continue;
      }
      $out[] = $rel;
    }
  }

  sort($out, SORT_NATURAL | SORT_FLAG_CASE);
  return array_values(array_unique($out));
}

function pb_app_group_options(PDO $pdo, string $selectedGroup = ''): array
{
  $out = [];
  foreach (catalogo_groups_fetch($pdo, 'apps') as $groupRow) {
    $groupName = catalogo_normalize_group_name($groupRow['group_name'] ?? 'Mis apps', 'apps');
    if ($groupName !== '') {
      $out[] = $groupName;
    }
  }

  $selectedGroup = catalogo_normalize_group_name($selectedGroup, 'apps');
  if ($selectedGroup !== '' && in_array($selectedGroup, $out, true)) {
    array_unshift($out, $selectedGroup);
  }

  if (empty($out)) {
    $out[] = 'Mis apps';
  }

  return array_values(array_unique($out));
}

function pb_project_file_path(string $publicRoot, string $relativePath): ?string
{
  $relativePath = pb_normalize_project_relative_path($relativePath);
  if ($relativePath === '') {
    return null;
  }

  $root = realpath($publicRoot);
  $path = realpath(rtrim($publicRoot, "/\\") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath));
  if ($root === false || $path === false || !is_file($path)) {
    return null;
  }

  $rootNorm = rtrim(str_replace('\\', '/', $root), '/') . '/';
  $pathNorm = str_replace('\\', '/', $path);
  if (stripos($pathNorm, $rootNorm) !== 0) {
    return null;
  }

  return $path;
}

function pb_copy_support_scripts_to_package(string $publicRoot, string $packageRoot, array $scripts, array &$errors): void
{
  foreach (pb_normalize_support_scripts($scripts, $errors) as $script) {
    $sourcePath = pb_project_file_path($publicRoot, (string) $script['source_path']);
    if ($sourcePath === null) {
      $errors[] = 'No se encontro el script auxiliar: ' . (string) $script['source_path'];
      continue;
    }

    $packagePath = pb_normalize_package_relative_path((string) $script['package_path']);
    if ($packagePath === '') {
      $errors[] = 'Ruta destino no valida para script auxiliar: ' . (string) $script['source_path'];
      continue;
    }

    $target = rtrim($packageRoot, "/\\") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $packagePath);
    @mkdir(dirname($target), 0775, true);
    if (!@copy($sourcePath, $target)) {
      $errors[] = 'No se pudo copiar el script auxiliar ' . (string) $script['source_path'] . ' a ' . $packagePath;
    }
  }
}

function pb_support_scripts_markdown(array $scripts): string
{
  $errors = [];
  $scripts = pb_normalize_support_scripts($scripts, $errors);
  if (empty($scripts)) {
    return '';
  }

  $out = "\n## Scripts auxiliares\n\n";
  foreach ($scripts as $script) {
    $out .= '- `' . (string) $script['package_path'] . '`';
    if ((string) $script['description'] !== '') {
      $out .= ': ' . (string) $script['description'];
    }
    $out .= "\n";
    if ((string) $script['source_path'] !== '') {
      $out .= '  - Origen: `' . (string) $script['source_path'] . "`\n";
    }
    if ((string) $script['instructions'] !== '') {
      $out .= '  - Ejecucion: ' . str_replace(["\r\n", "\r", "\n"], ' ', (string) $script['instructions']) . "\n";
    }
  }

  return $out;
}

function pb_readme_content(array $m): string
{
  $content = "# " . (string) $m['name'] . " (" . (string) $m['version'] . ")\n\n";
  $content .= "Plugin generado desde /apps/" . (string) $m['source_app'] . ".\n";
  $content .= pb_support_scripts_markdown((array) ($m['support_scripts'] ?? []));
  return $content;
}

function publish_to_store(string $zipFile, array &$errors): ?array
{
  $token = '';

  $base = 'https://sede.csifsevilla.es/plugin-store/';



  foreach (['/etc/intranet/intranet-config.php', '/etc/csif/intranet-config.php'] as $cfg) {

    if (!is_file($cfg)) {

      continue;

    }

    require_once $cfg;

    if (defined('PLUGIN_STORE_PUBLISH_TOKEN')) {

      $token = (string) PLUGIN_STORE_PUBLISH_TOKEN;

    }

    if (defined('PLUGIN_STORE_BASE_URL') && PLUGIN_STORE_BASE_URL) {

      $base = rtrim((string) PLUGIN_STORE_BASE_URL, '/') . '/';

    }

    break;

  }



  if ($token === '' && defined('PLUGIN_STORE_PUBLISH_TOKEN') && PLUGIN_STORE_PUBLISH_TOKEN) {

    $token = (string) PLUGIN_STORE_PUBLISH_TOKEN;

  }

  if (defined('PLUGIN_STORE_BASE_URL') && PLUGIN_STORE_BASE_URL) {

    $base = rtrim((string) PLUGIN_STORE_BASE_URL, '/') . '/';

  } elseif (defined('PLUGIN_STORE_PUBLISH_URL') && PLUGIN_STORE_PUBLISH_URL) {

    $publishUrl = (string) PLUGIN_STORE_PUBLISH_URL;

    $suffix = '/api/publish-core-update.php';

    if (substr($publishUrl, -strlen($suffix)) === $suffix) {

      $base = substr($publishUrl, 0, -strlen($suffix)) . '/';

    }

  }



  if ($token === '') {

    $errors[] = 'Falta PLUGIN_STORE_PUBLISH_TOKEN en common/config.php, config/secrets.local.php, variables de entorno o en las rutas legacy /etc/intranet/intranet-config.php y /etc/csif/intranet-config.php.';

    return null;

  }

  if (!function_exists('curl_init')) {

    $errors[] = 'Falta PHP cURL (instala php-curl/php8.x-curl).';

    return null;

  }

  if (!is_file($zipFile) || filesize($zipFile) < 10) {

    $errors[] = 'ZIP no encontrado o vacío: ' . $zipFile;

    return null;

  }



  $url = $base . 'api/publish.php';

  $ch = curl_init($url);



  $post = [

    'file' => new CURLFile($zipFile, 'application/zip', basename($zipFile)),

    'force' => '1'

  ];

  $headers = [

    'X-Plugin-Token: ' . $token,

    'Expect:',

  ];



  curl_setopt_array($ch, [

    CURLOPT_POST => true,

    CURLOPT_POSTFIELDS => $post,

    CURLOPT_RETURNTRANSFER => true,

    CURLOPT_TIMEOUT => 180,

    CURLOPT_HTTPHEADER => $headers,

    CURLOPT_SSL_VERIFYPEER => true,

    CURLOPT_SSL_VERIFYHOST => 2,

    CURLOPT_SAFE_UPLOAD => true,

  ]);



  $resp = curl_exec($ch);

  $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);

  $err = curl_error($ch);

  curl_close($ch);



  if ($resp === false) {

    $errors[] = 'Error cURL: ' . $err;

    return null;

  }



  $data = json_decode((string) $resp, true);

  if ($http !== 200 || !is_array($data) || !($data['ok'] ?? false)) {

    $errors[] = 'Publish falló (HTTP ' . $http . '): ' . substr((string) $resp, 0, 400);

    return null;

  }



  return $data;

}



// ---------------- MAIN ----------------

$apps = list_apps($appsPath);

$errors = [];



$prefApp = (string) ($_GET['app'] ?? '');

$prefApp = ($prefApp && in_array($prefApp, $apps, true)) ? $prefApp : '';



$prefName = '';

$prefVersion = '1.0.0';

$prefDesc = '';

$prefIcon = 'box';
$prefGroup = 'Mis apps';
$prefRoles = json_encode(['administradores'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$prefNewTab = 0;
$prefBumpType = 'c';
$prefAutoBump = 1;
$prefAction = 'publish';
$prefPluginId = $prefApp ? slugify($prefApp) : '';
$prefAppFolder = $prefApp ? slugify($prefApp) : '';
$prefSqlScripts = [];
$prefScheduledTasks = [];
$prefSupportScripts = [];
$prefChangelog = [];
$prefReleaseNotes = '';
$prefTaskCandidates = [];
$prefNeedsPluginConversion = false;
$prefExistingPlugin = false;


if ($prefApp !== '') {

  $row = intranet_apps_row($pdo, $prefApp);

  if (is_array($row)) {

    if (!empty($row['name']))

      $prefName = (string) $row['name'];

    if (!empty($row['version']))

      $prefVersion = (string) $row['version'];

    if (!empty($row['description']))

      $prefDesc = (string) $row['description'];

    if (!empty($row['icon']))

      $prefIcon = (string) $row['icon'];

    if (!empty($row['group_name']))

      $prefGroup = (string) $row['group_name'];

    if (!empty($row['roles_json']))

      $prefRoles = (string) $row['roles_json'];

    if (isset($row['new_tab']))

      $prefNewTab = (int) $row['new_tab'];

    if (!empty($row['app_id']))

      $prefPluginId = (string) $row['app_id'];

    if (!empty($row['url']) && preg_match('~^/apps/([^/]+)/~', (string) $row['url'], $m)) {

      $prefAppFolder = (string) $m[1];

    }

  }

}



if ($prefApp !== '') {

  $cache = pb_cache_row($pdo, $prefApp);

  if (is_array($cache)) {

    if (!empty($cache['name']))

      $prefName = (string) $cache['name'];

    if (!empty($cache['version']))

      $prefVersion = (string) $cache['version'];

    if (!empty($cache['description']))

      $prefDesc = (string) $cache['description'];

    if (!empty($cache['icon']))

      $prefIcon = (string) $cache['icon'];

    if (!empty($cache['default_group_name']))

      $prefGroup = (string) $cache['default_group_name'];

    if (!empty($cache['roles_json']))

      $prefRoles = (string) $cache['roles_json'];

    if (isset($cache['new_tab']))

      $prefNewTab = (int) $cache['new_tab'];

    if (!empty($cache['plugin_id']))

      $prefPluginId = (string) $cache['plugin_id'];

    if (!empty($cache['app_folder']))

      $prefAppFolder = (string) $cache['app_folder'];

  }

}



if ($prefApp !== '') {

  $aj = pb_read_app_json($appsPath, $prefApp);

  if (is_array($aj)) {

    if (!empty($aj['name']))

      $prefName = (string) $aj['name'];

    if (!empty($aj['version']))

      $prefVersion = (string) $aj['version'];

    if (!empty($aj['description']))

      $prefDesc = (string) $aj['description'];

    if (!empty($aj['icon']))

      $prefIcon = (string) $aj['icon'];

    if (!empty($aj['default_group_name']))

      $prefGroup = (string) $aj['default_group_name'];

    if (isset($aj['roles_json']) && is_array($aj['roles_json'])) {

      $prefRoles = json_encode($aj['roles_json'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    }

    if (isset($aj['new_tab']))

      $prefNewTab = (int) $aj['new_tab'];

    if (!empty($aj['plugin_id']))

      $prefPluginId = (string) $aj['plugin_id'];

    if (!empty($aj['app_folder']))

      $prefAppFolder = (string) $aj['app_folder'];

    if (isset($aj['sql_scripts']) && is_array($aj['sql_scripts'])) {
      $prefSqlScripts = $aj['sql_scripts'];
    }
    if (isset($aj['scheduled_tasks']) && is_array($aj['scheduled_tasks'])) {
      $prefScheduledTasks = $aj['scheduled_tasks'];
    }
    if (isset($aj['support_scripts']) && is_array($aj['support_scripts'])) {
      $prefSupportScripts = $aj['support_scripts'];
    }
    if (isset($aj['changelog']) && is_array($aj['changelog'])) {
      $prefChangelog = $aj['changelog'];
    }
  }
}

if ($prefApp !== '') {
  $localManifest = pb_read_local_plugin_manifest($publicRoot, [
    'source_app' => $prefApp,
    'plugin_id' => $prefPluginId,
    'app_folder' => $prefAppFolder,
  ]);
  if (is_array($localManifest)) {
    $prefExistingPlugin = true;
    if (!empty($localManifest['version'])) {
      $prefVersion = (string) $localManifest['version'];
    }
    if (isset($localManifest['changelog']) && is_array($localManifest['changelog'])) {
      $prefChangelog = $localManifest['changelog'];
    }
    if (isset($localManifest['support_scripts']) && is_array($localManifest['support_scripts'])) {
      $prefSupportScripts = $localManifest['support_scripts'];
    }
  }
}

if ($prefApp !== '' && !empty($_SESSION['pb_last_form']) && ($_SESSION['pb_last_form_app'] ?? '') === $prefApp) {
  $lf = $_SESSION['pb_last_form'];
  if (is_array($lf)) {

    if (isset($lf['name']))

      $prefName = (string) $lf['name'];

    if (isset($lf['version']))

      $prefVersion = (string) $lf['version'];

    if (isset($lf['description']))

      $prefDesc = (string) $lf['description'];

    if (isset($lf['icon']))

      $prefIcon = (string) $lf['icon'];

    if (isset($lf['default_group_name']))

      $prefGroup = (string) $lf['default_group_name'];

    if (isset($lf['roles_json_str']))

      $prefRoles = (string) $lf['roles_json_str'];

    if (isset($lf['new_tab']))
      $prefNewTab = (int) $lf['new_tab'];
    if (isset($lf['bump_type']))
      $prefBumpType = (string) $lf['bump_type'];
    if (isset($lf['auto_bump']))
      $prefAutoBump = (int) $lf['auto_bump'];
    if (isset($lf['do']))
      $prefAction = (string) $lf['do'];
    if (isset($lf['plugin_id']))
      $prefPluginId = (string) $lf['plugin_id'];
    if (isset($lf['app_folder']))

      $prefAppFolder = (string) $lf['app_folder'];

    if (isset($lf['sql_scripts']) && is_array($lf['sql_scripts'])) {
      $prefSqlScripts = $lf['sql_scripts'];
    }
    if (isset($lf['scheduled_tasks']) && is_array($lf['scheduled_tasks'])) {
      $prefScheduledTasks = $lf['scheduled_tasks'];
    }
    if (isset($lf['support_scripts']) && is_array($lf['support_scripts'])) {
      $prefSupportScripts = $lf['support_scripts'];
    }
    if (isset($lf['changelog']) && is_array($lf['changelog'])) {
      $prefChangelog = $lf['changelog'];
    }
    if (isset($lf['release_notes'])) {
      $prefReleaseNotes = (string) $lf['release_notes'];
    }
  }
}

$detectedSqlFiles = $prefApp !== '' ? pb_list_sql_files($appsPath, $prefApp) : [];
$prefSqlScripts = pb_normalize_sql_scripts($detectedSqlFiles, $prefSqlScripts);
$prefScheduledTasks = pb_normalize_scheduled_tasks($prefScheduledTasks);
$prefSupportScriptErrors = [];
$prefSupportScripts = pb_normalize_support_scripts($prefSupportScripts, $prefSupportScriptErrors);
$prefChangelog = pb_changelog_normalize($prefChangelog);
$prefTaskCandidates = $prefApp !== '' ? pb_list_task_candidate_files($appsPath, $prefApp) : [];
$prefSupportScriptCandidates = pb_list_support_script_candidates($publicRoot, $prefApp);
$prefNeedsPluginConversion = $prefApp !== '' ? pb_source_app_needs_plugin_conversion($appsPath, $prefApp) : false;
$prefRoleList = intranet_roles_from_post($prefRoles, ['administradores']);
$prefRoles = intranet_roles_to_json($prefRoleList, ['administradores']);
$availableRoles = intranet_available_roles($pdo, $prefRoleList);
$prefBumpType = in_array($prefBumpType, ['a', 'b', 'c'], true) ? $prefBumpType : 'c';
$prefAutoBump = $prefAutoBump ? 1 : 0;
$prefAction = in_array($prefAction, ['download', 'publish', 'both'], true) ? $prefAction : 'publish';
$prefGroup = catalogo_normalize_group_name($prefGroup, 'apps');
$appGroupOptions = pb_app_group_options($pdo, $prefGroup);
if ($prefGroup === '' || !in_array($prefGroup, $appGroupOptions, true)) {
  $prefGroup = in_array('Mis apps', $appGroupOptions, true) ? 'Mis apps' : (string) $appGroupOptions[0];
}
$prefDetectedTables = $prefApp !== '' ? pb_detect_plugin_tables($appsPath, $prefApp) : [];
$prefLocalPluginDir = $prefApp !== '' ? pb_find_local_plugin_dir($publicRoot, [
  'source_app' => $prefApp,
  'plugin_id' => $prefPluginId,
  'app_folder' => $prefAppFolder,
]) : null;
$prefLocalPhpInfo = $prefLocalPluginDir !== null ? pb_latest_local_plugin_php($prefLocalPluginDir) : [
  'count' => 0,
  'mtime' => 0,
  'path' => '',
];
$prefStoreErrors = [];
$prefStorePlugin = $prefPluginId !== '' ? pb_remote_store_plugin($prefPluginId, $prefStoreErrors) : null;
$prefStoreVersion = is_array($prefStorePlugin) ? (string) ($prefStorePlugin['version'] ?? '') : '';
$prefStorePublishedAt = is_array($prefStorePlugin) ? (string) ($prefStorePlugin['published_at'] ?? '') : '';
$prefTableSchemas = [];
if (!empty($prefDetectedTables)) {
  try {
    $prefTableSchemas = pb_db_table_schemas($pdo, array_keys($prefDetectedTables));

  } catch (Throwable $e) {

    $errors[] = 'No se pudo consultar INFORMATION_SCHEMA para las tablas detectadas: ' . $e->getMessage();

  }

}



if ($_SERVER['REQUEST_METHOD'] === 'POST') {

  $pcsrf = (string) ($_POST['csrf'] ?? '');

  if (!$pcsrf || !hash_equals($csrf, $pcsrf)) {

    $errors[] = 'CSRF inválido';

  } else {
    $sourceApp = (string) ($_POST['source_app'] ?? '');
    $detectedSqlFiles = pb_list_sql_files($appsPath, $sourceApp);
    $sqlScripts = pb_sql_scripts_from_post($detectedSqlFiles, $_POST);
    $scheduledTasks = pb_scheduled_tasks_from_post($_POST, $errors);
    $supportScripts = pb_support_scripts_from_post($_POST, $errors);
    $pluginId = slugify((string) ($_POST['plugin_id'] ?? ''));
    $name = trim((string) ($_POST['name'] ?? ''));
    $verCurrent = trim((string) ($_POST['version'] ?? '1.0.0'));
    $bumpType = (string) ($_POST['bump_type'] ?? 'c');
    $bumpType = in_array($bumpType, ['a', 'b', 'c'], true) ? $bumpType : 'c';
    $autoBump = !empty($_POST['auto_bump']);
    $ver = $autoBump ? pb_semver_next($verCurrent, $bumpType) : $verCurrent;
    $desc = trim((string) ($_POST['description'] ?? ''));
    $appFolder = slugify((string) ($_POST['app_folder'] ?? $sourceApp));
    $needsPluginConversion = pb_source_app_needs_plugin_conversion($appsPath, $sourceApp);
    $releaseNotesRaw = trim((string) ($_POST['release_notes'] ?? ''));
    $releaseNotesList = pb_release_notes_to_list($releaseNotesRaw);

    $icon = trim((string) ($_POST['icon'] ?? 'box'));
    $rolesJson = intranet_roles_from_post($_POST['roles_json'] ?? [], ['administradores']);
    $rolesJsonStr = intranet_roles_to_json($rolesJson, ['administradores']);
    $newTab = (int) ($_POST['new_tab'] ?? 0) ? 1 : 0;

    $defaultGroup = catalogo_normalize_group_name($_POST['default_group_name'] ?? 'Mis apps', 'apps');
    if ($defaultGroup === '') {
      $defaultGroup = 'Mis apps';
    }
    $action = (string) ($_POST['do'] ?? 'publish');
    if (!in_array($action, ['download', 'publish', 'both'], true)) {
      $action = 'publish';
    }

    if ($sourceApp === '' || !is_dir($appsPath . DIRECTORY_SEPARATOR . $sourceApp))

      $errors[] = 'App origen inválida';

    if ($name === '')

      $errors[] = 'Nombre requerido';

    if ($pluginId === '')

      $errors[] = 'ID requerido';

    if ($appFolder === '')
      $errors[] = 'app_folder requerido';
    if (!catalogo_group_exists($pdo, 'apps', $defaultGroup))
      $errors[] = 'El grupo de menu seleccionado no existe en el catalogo de aplicaciones.';
    if ($needsPluginConversion && $appFolder !== $sourceApp) {
      $targetAppPath = $appsPath . DIRECTORY_SEPARATOR . $appFolder;
      if (file_exists($targetAppPath) && !is_link($targetAppPath)) {
        $errors[] = 'Para convertir una app existente a plugin, usa el mismo app_folder que la carpeta actual o deja libre la ruta destino en /apps.';
      }
    }
    if (!is_file($templateInstaller))
      $errors[] = 'Falta plantilla installer: ' . $templateInstaller;
    if (!is_file($templateUninstaller))
      $errors[] = 'Falta plantilla uninstaller: ' . $templateUninstaller;

    $existingManifest = pb_read_local_plugin_manifest($publicRoot, [
      'source_app' => $sourceApp,
      'plugin_id' => $pluginId,
      'app_folder' => $appFolder,
    ]);
    $existingChangelog = is_array($existingManifest) && isset($existingManifest['changelog']) && is_array($existingManifest['changelog'])
      ? $existingManifest['changelog']
      : [];
    $isExistingPluginVersion = is_array($existingManifest) || !$needsPluginConversion;
    if ($isExistingPluginVersion && $ver !== $verCurrent && empty($releaseNotesList)) {
      $errors[] = 'Debes indicar los cambios de la nueva versión antes de generar o publicar el plugin.';
    }

    $formState = [
      'source_app' => $sourceApp,
      'plugin_id' => $pluginId,
      'app_folder' => $appFolder,
      'name' => $name,
      'version' => $verCurrent,
      'target_version' => $ver,
      'bump_type' => $bumpType,
      'auto_bump' => $autoBump ? 1 : 0,
      'do' => $action,
      'description' => $desc,
      'icon' => $icon,
      'roles_json_str' => $rolesJsonStr,
      'sql_scripts' => $sqlScripts,
      'scheduled_tasks' => $scheduledTasks,
      'support_scripts' => $supportScripts,
      'changelog' => pb_changelog_merge($existingChangelog, $ver, $releaseNotesList),
      'release_notes' => $releaseNotesRaw,
      'new_tab' => $newTab,
      'default_group_name' => $defaultGroup,
    ];

    $_SESSION['pb_last_form'] = $formState;
    $_SESSION['pb_last_form_app'] = $sourceApp;

    $prefApp = in_array($sourceApp, $apps, true) ? $sourceApp : '';
    $prefPluginId = $pluginId;
    $prefAppFolder = $appFolder;
    $prefName = $name;
    $prefVersion = $verCurrent;
    $prefDesc = $desc;
    $prefIcon = $icon;
    $prefRoles = $rolesJsonStr;
    $prefRoleList = intranet_roles_from_post($prefRoles, ['administradores']);
    $prefRoles = intranet_roles_to_json($prefRoleList, ['administradores']);
    $availableRoles = intranet_available_roles($pdo, $prefRoleList);
    $prefNewTab = $newTab;
    $prefBumpType = $bumpType;
    $prefAutoBump = $autoBump ? 1 : 0;
    $prefAction = $action;
    $prefGroup = $defaultGroup;
    $prefSqlScripts = $sqlScripts;
    $prefScheduledTasks = $scheduledTasks;
    $prefSupportScripts = $supportScripts;
    $prefChangelog = $formState['changelog'];
    $prefReleaseNotes = $releaseNotesRaw;
    $prefExistingPlugin = is_array($existingManifest);
    $prefNeedsPluginConversion = $needsPluginConversion;
    $prefTaskCandidates = $prefApp !== '' ? pb_list_task_candidate_files($appsPath, $prefApp) : [];
    $prefSupportScriptCandidates = pb_list_support_script_candidates($publicRoot, $prefApp);
    $appGroupOptions = pb_app_group_options($pdo, $prefGroup);
    if ($prefGroup === '' || !in_array($prefGroup, $appGroupOptions, true)) {
      $prefGroup = in_array('Mis apps', $appGroupOptions, true) ? 'Mis apps' : (string) $appGroupOptions[0];
    }
    $prefDetectedTables = $prefApp !== '' ? pb_detect_plugin_tables($appsPath, $prefApp) : [];
    $prefTableSchemas = [];
    if (!empty($prefDetectedTables)) {
      try {
        $prefTableSchemas = pb_db_table_schemas($pdo, array_keys($prefDetectedTables));
      } catch (Throwable $e) {
        $errors[] = 'No se pudo consultar INFORMATION_SCHEMA para las tablas detectadas: ' . $e->getMessage();
      }
    }

    if (!$errors) {
      $buildTimestamp = date('c');
      $save = $formState;
      $save['version'] = $ver;
      $save['updated_at'] = $buildTimestamp;


      $buildRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'plugin_build_' . bin2hex(random_bytes(6));

      @mkdir($buildRoot, 0775, true);



      $manifest = pb_local_plugin_manifest_payload($buildRoot, $save);


      file_put_contents(

        $buildRoot . '/plugin.json',

        json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)

      );



      file_put_contents(

        $buildRoot . '/README.md',

        pb_readme_content($save)
      );


      $dstApp = $buildRoot . '/apps/' . $appFolder;

      copy_tree($appsPath . DIRECTORY_SEPARATOR . $sourceApp, $dstApp, $errors);

      pb_write_app_json_file($dstApp . '/app.json', $save, $errors);

      pb_pluginize_app_tree($dstApp, $errors);



      @mkdir($buildRoot . '/install', 0775, true);



      $meta = [
        'id' => $pluginId,
        'app_id' => $pluginId,
        'name' => $name,
        'version' => $ver,

        'url' => 'apps/' . $appFolder . '/index.php',

        'description' => $desc,

        'icon' => ($icon ?: 'box'),

        'image' => null,

        'new_tab' => $newTab,

        'roles_json' => $rolesJson,
        'default_group_name' => ($defaultGroup ?: 'Mis apps'),
        'app_folder' => $appFolder,
        'sql_scripts' => $sqlScripts,
        'scheduled_tasks' => $scheduledTasks,
        'support_scripts' => $supportScripts,
        'changelog' => $save['changelog'],
        'app' => [
          'app_id' => $pluginId,

          'folder' => $appFolder,

          'url' => 'apps/' . $appFolder . '/index.php',

          'default_group_name' => ($defaultGroup ?: 'Mis apps'),

        ],

      ];



      $installerTpl = (string) file_get_contents($templateInstaller);

      $uninstallerTpl = (string) file_get_contents($templateUninstaller);



      $installerOut = str_replace(

        '__PLUGIN_META_JSON__',

        json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),

        $installerTpl

      );



      if (strpos($uninstallerTpl, '__PLUGIN_META_JSON__') !== false) {

        $uninstallerOut = str_replace(

          '__PLUGIN_META_JSON__',

          json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),

          $uninstallerTpl

        );

      } else {

        $uninstallerOut = str_replace(['__PLUGIN_ID__', '__APP_FOLDER__'], [$pluginId, $appFolder], $uninstallerTpl);

      }



      file_put_contents($buildRoot . '/install/install.php', $installerOut);

      file_put_contents($buildRoot . '/install/installer.php', $installerOut);
      file_put_contents($buildRoot . '/install/uninstall.php', $uninstallerOut);
      file_put_contents($buildRoot . '/install/uninstaller.php', $uninstallerOut);
      pb_copy_support_scripts_to_package($publicRoot, $buildRoot, $supportScripts, $errors);

      if (!$errors && ($action === 'publish' || $action === 'both')) {
        $cmpErrors = [];

        $sameCheck = pb_store_same_package($pluginId, $buildRoot, $cmpErrors);

        if (is_array($sameCheck) && !empty($sameCheck['same'])) {

          $remoteVersion = (string) (($sameCheck['remote']['version'] ?? '') ?: 'sin versión');

          $errors[] = 'El plugin ya publicado en el Store tiene el mismo contenido que el generado localmente. No se publicará una nueva versión vacía. Versión remota: ' . $remoteVersion . '.';

        }

      }



      if (!$errors) {

        pb_upsert_builder_cache($pdo, $save, $errors);

        pb_update_intranet_apps($pdo, $save, $errors);

        pb_write_app_json($appsPath, $save, $errors);

      }



      $zipFile = $buildOutDir . DIRECTORY_SEPARATOR . $pluginId . '-' . $ver . '.zip';

      if (!$errors) {

        zip_dir($buildRoot, $zipFile, $errors);

      }



      if (!$errors && is_file($zipFile)) {
        error_log("PB zipFile=$zipFile exists=1 size=" . filesize($zipFile));

        $published = false;
        if ($action === 'publish' || $action === 'both') {
          $pub = publish_to_store($zipFile, $errors);
          if ($pub) {
            $published = true;
            $_SESSION['pb_last_publish'] = $pub;
            $_SESSION['pb_last_publish_sync'] = false;
          }
        }

        if (!$errors && $needsPluginConversion) {
          $swap = pb_stage_existing_app_dir($appsPath, $sourceApp, $errors);
          if ($swap !== null) {
            $localInstall = pb_install_local_plugin_zip($pdo, $zipFile, $publicRoot, [
              'plugin_id' => $pluginId,
              'group_name' => $defaultGroup,
              'app_folder' => $appFolder,
              'version' => $ver,
              'source_url' => $published ? '' : '',
              'sha256' => hash_file('sha256', $zipFile) ?: '',
              'sql_strategy' => 'auto',
            ], $errors);

            if (is_array($localInstall) && !empty($localInstall['ok']) && empty($localInstall['sql_only'])) {
              pb_discard_staged_app_dir($swap);
              $_SESSION['pb_last_local_conversion'] = [
                'plugin_id' => $pluginId,
                'plugin_name' => $name,
                'app_folder' => $appFolder,
                'source_app' => $sourceApp,
                'msg' => (string) ($localInstall['msg'] ?? 'App convertida a plugin local.'),
              ];
            } else {
              pb_restore_staged_app_dir($swap, $errors);
              if (is_array($localInstall) && !empty($localInstall['msg'])) {
                $errors[] = 'La conversion local a plugin no se completo: ' . (string) $localInstall['msg'];
              } elseif ($localInstall === null) {
                $errors[] = 'La conversion local a plugin no se pudo completar.';
              }
            }
          }
        }

        if (!$errors && ($published || !$needsPluginConversion)) {
          $_SESSION['pb_last_publish_sync'] = pb_sync_local_plugin_source($publicRoot, $save, $templateInstaller, $templateUninstaller, $errors);
          if ($published && !empty($_SESSION['pb_last_publish_sync'])) {
            $_SESSION['pb_last_publish_registry_sync'] = pb_sync_installed_plugin_registry($pdo, $save, $pub ?? null, $zipFile, $errors);
          }
        }

        if (!$errors) {
          $_SESSION['pb_last_form'] = $save;
          $_SESSION['pb_last_form_app'] = $sourceApp;
          $prefVersion = $ver;
        }

        if (!$errors && ($action === 'download' || $action === 'both')) {
          header('Content-Type: application/zip');
          header('Content-Disposition: attachment; filename="' . basename($zipFile) . '"');

          header('Content-Length: ' . filesize($zipFile));

          readfile($zipFile);

          exit;

        }

      }

    }

  }

}

?>

<!doctype html>

<html lang="es" class="app-html" <?= intranet_theme_html_attr(); ?>>



<head>

  <meta charset="utf-8">

  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Plugin Builder</title>

  <?= intranet_theme_bootstrap_script_tag(); ?>

  <link rel="stylesheet" href="<?= h(asset_url('styles/css/style.css')) ?>">

  <link rel="stylesheet" href="<?= h(asset_url('styles/css/theme-vars.css')) ?>">

  <style>

    .pb-shell {

      width: calc(100% - 28px);

      max-width: none;

      margin: 14px;

      display: grid;

      gap: 14px;

    }



    .pb-banner {

      margin: 0;

      width: 100%;

      max-width: none;

    }



    .pb-builder-card {
      margin: 0;
      padding: 18px;
      border-radius: 14px;
      box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
    }

    .pb-wizard {
      display: grid;
      gap: 16px;
    }

    .pb-wizard-progress {
      display: grid;
      grid-template-columns: repeat(5, minmax(0, 1fr));
      gap: 10px;
    }

    .pb-wizard-pill {
      display: grid;
      gap: 4px;
      padding: 12px 14px;
      border-radius: 12px;
      border: 1px solid var(--border-light);
      background: var(--bg-input);
      color: var(--text-muted);
      cursor: pointer;
      text-align: left;
    }

    .pb-wizard-pill strong {
      color: var(--text-main);
      font-size: .95rem;
    }

    .pb-wizard-pill.is-active {
      border-color: rgba(13, 110, 253, 0.28);
      background: color-mix(in srgb, var(--bg-card) 84%, rgba(13, 110, 253, 0.12) 16%);
      box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
    }

    .pb-wizard-pill.is-done {
      border-color: rgba(6, 123, 59, 0.24);
      background: rgba(6, 123, 59, 0.10);
    }

    .pb-step {
      display: none;
      gap: 14px;
    }

    .pb-step.is-active {
      display: grid;
    }

    .pb-step-head {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 14px;
      flex-wrap: wrap;
    }

    .pb-step-kicker {
      color: var(--text-muted);
      font-size: .88rem;
      text-transform: uppercase;
      letter-spacing: .05em;
    }

    .pb-step-actions {
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
      margin-top: 4px;
    }

    .pb-step-actions--end {
      justify-content: flex-end;
    }

    .pb-review-card {
      display: grid;
      gap: 12px;
      padding: 14px;
      border: 1px solid var(--border-light);
      border-radius: 12px;
      background: color-mix(in srgb, var(--bg-card) 90%, var(--bg-input) 10%);
    }

    .pb-review-grid {
      display: grid;
      gap: 12px;
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }

    .pb-review-item {
      display: grid;
      gap: 4px;
      padding: 12px;
      border-radius: 12px;
      background: var(--bg-input);
      border: 1px solid var(--border-light);
    }

    .pb-review-item code {
      word-break: break-word;
    }

    .pb-convert-note {
      padding: 12px 14px;
      border-radius: 12px;
      border: 1px solid rgba(13, 110, 253, 0.18);
      background: color-mix(in srgb, var(--bg-card) 88%, rgba(13, 110, 253, 0.12) 12%);
    }


    .pb-form {

      display: grid;

      gap: 14px;

    }



    .pb-field-block {

      display: grid;

      gap: 6px;

    }



    .pb-field-block > .hint,

    .pb-form > div > .hint {

      margin-bottom: 0 !important;

    }



    .pb-sql-box {

      display: grid;

      gap: 12px;

      padding: 14px;

    }



    .pb-sql-head {

      display: flex;

      justify-content: space-between;

      align-items: center;

      gap: 12px;

      flex-wrap: wrap;

    }



    .pb-inline-note,

    .pb-note {

      color: var(--text-muted);

      line-height: 1.55;

    }



    .pb-sql-list {
      display: grid;
      gap: 10px;
    }

    .pb-cron-box {
      display: grid;
      gap: 12px;
      padding: 14px;
    }

    .pb-cron-list {
      display: grid;
      gap: 12px;
    }

    .pb-cron-row {
      display: grid;
      gap: 12px;
      padding: 14px;
      border: 1px solid var(--border-light);
      border-radius: 12px;
      background: color-mix(in srgb, var(--bg-card) 90%, var(--bg-input) 10%);
    }

    .pb-cron-top {
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 12px;
      flex-wrap: wrap;
    }

    .pb-cron-toggle {
      display: inline-flex;
      align-items: center;
      gap: 10px;
      color: var(--text-main);
      font-weight: 700;
    }

    .pb-cron-grid {
      display: grid;
      gap: 10px;
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }

    .pb-cron-grid--wide {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }

    .pb-cron-row textarea {
      min-height: 82px;
      resize: vertical;
    }

    .pb-cron-actions {
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
    }

    .pb-cron-remove {
      border: 1px solid rgba(220, 38, 38, 0.24);
      background: rgba(220, 38, 38, 0.10);
      color: var(--danger-color);
    }


    .pb-db-box {

      display: grid;

      gap: 12px;

      padding: 14px;

    }



    .pb-db-list {

      display: grid;

      gap: 10px;

    }



    .pb-db-card {

      border: 1px solid var(--border-light);

      border-radius: 12px;

      background: color-mix(in srgb, var(--bg-card) 90%, var(--bg-input) 10%);

      overflow: hidden;

    }



    .pb-db-summary {

      list-style: none;

      display: flex;

      justify-content: space-between;

      align-items: flex-start;

      gap: 12px;

      padding: 12px 14px;

      cursor: pointer;

    }



    .pb-db-summary::-webkit-details-marker {

      display: none;

    }



    .pb-db-body {

      border-top: 1px solid var(--border-light);

      padding: 12px 14px 14px;

      display: grid;

      gap: 12px;

    }



    .pb-db-meta {

      color: var(--text-muted);

      line-height: 1.55;

    }



    .pb-db-files {

      display: flex;

      flex-wrap: wrap;

      gap: 8px;

    }



    .pb-db-file {

      display: inline-flex;

      align-items: center;

      min-height: 28px;

      padding: 0 10px;

      border-radius: 999px;

      background: var(--bg-input);

      border: 1px solid var(--border-light);

      color: var(--text-main);

      font-size: .86rem;

    }



    .pb-db-status {

      display: inline-flex;

      align-items: center;

      justify-content: center;

      min-height: 34px;

      padding: 0 12px;

      border-radius: 999px;

      border: 1px solid rgba(6, 123, 59, 0.24);

      background: rgba(6, 123, 59, 0.10);

      color: var(--primary);

      font-weight: 800;

      white-space: nowrap;

    }



    .pb-db-status--missing {
      border-color: rgba(220, 38, 38, 0.24);
      background: rgba(220, 38, 38, 0.10);
      color: var(--danger-color);
    }

    .pb-db-status--planned {
      border-color: rgba(217, 119, 6, 0.28);
      background: rgba(217, 119, 6, 0.12);
      color: #b45309;
    }

    .pb-db-table-wrap {

      overflow-x: auto;

    }



    .pb-db-table {

      width: 100%;

      border-collapse: collapse;

      font-size: .92rem;

    }



    .pb-db-table th,

    .pb-db-table td {

      padding: 10px 12px;

      border-bottom: 1px solid var(--border-light);

      text-align: left;

      vertical-align: top;

    }



    .pb-db-table th {

      background: var(--bg-input);

      color: var(--text-main);

      font-size: .82rem;

      text-transform: uppercase;

      letter-spacing: .02em;

    }



    .pb-sql-row {

      display: grid;

      grid-template-columns: minmax(260px, 2fr) 110px 150px 150px 110px;

      gap: 10px;

      align-items: center;

      padding: 12px;

      border: 1px solid var(--border-light);

      border-radius: 12px;

      background: color-mix(in srgb, var(--bg-card) 90%, var(--bg-input) 10%);

    }



    .pb-sql-toggle {

      display: flex;

      gap: 10px;

      align-items: flex-start;

      margin: 0;

      color: var(--text-main);

    }



    .pb-sql-toggle .hint {

      display: inline-block;

      margin-top: 2px;

    }



    .pb-sql-state {

      display: inline-flex;

      align-items: center;

      justify-content: center;

      min-height: 42px;

      padding: 0 10px;

      border-radius: 999px;

      border: 1px solid rgba(6, 123, 59, 0.24);

      background: rgba(6, 123, 59, 0.10);

      color: var(--primary);

      font-weight: 800;

    }



    .pb-sql-state--off {

      border-color: rgba(100, 116, 139, 0.24);

      background: rgba(100, 116, 139, 0.10);

      color: var(--text-muted);

    }



    .pb-action-select {

      max-width: 260px;

    }



    .pb-store-link {

      text-decoration: none;

    }



    html[data-theme="dark"] .pb-builder-card {
      box-shadow: 0 16px 34px rgba(0, 0, 0, 0.18);
    }


    html[data-theme="dark"] .pb-sql-box,
    html[data-theme="dark"] .pb-review-card,
    html[data-theme="dark"] .pb-review-item,
    html[data-theme="dark"] .pb-sql-row,
    html[data-theme="dark"] .pb-cron-box,
    html[data-theme="dark"] .pb-cron-row,
    html[data-theme="dark"] .pb-db-box,
    html[data-theme="dark"] .pb-db-card {
      background: color-mix(in srgb, var(--bg-input) 88%, var(--bg-card) 12%);
    }



    @media (max-width: 1100px) {
      .pb-wizard-progress {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }

      .pb-review-grid {
        grid-template-columns: 1fr;
      }

      .pb-sql-row {
        grid-template-columns: minmax(200px, 2fr) repeat(4, minmax(110px, 1fr));
      }
    }



    @media (max-width: 900px) {

      .pb-sql-row {
        grid-template-columns: 1fr;
      }

      .pb-wizard-progress {
        grid-template-columns: 1fr;
      }

      .pb-cron-grid,
      .pb-cron-grid--wide {
        grid-template-columns: 1fr;
      }

      .pb-action-select {
        max-width: none;
      }

    }

  </style>

</head>



<body class="app-body">

  <header class="header-top">

    <div class="header-titles">

      <h2 style="margin:0;">Plugin Builder</h2>

      <p style="margin:5px 0 0 0; opacity:0.9;">Genera plugins ZIP y publícalos. Los ZIP se guardan en

        <code>/apps/backups</code>.

      </p>

    </div>

    <div class="header-actions">

      <a href="<?= h(asset_url('dashboard.php')) ?>" target="_top" class="btn-back-dashboard">Volver al inicio</a>

    </div>

  </header>



  <div class="pb-shell">

  <?php foreach ($errors as $e): ?>

    <div class="msg-card error pb-banner">

      <div class="msg-header">Aviso/Error</div>

      <div class="msg-body">

        <?php echo h($e); ?>

      </div>

    </div>

  <?php endforeach; ?>



  <?php

  $pub = $_SESSION['pb_last_publish'] ?? null;

  if (is_array($pub) && ($pub['ok'] ?? false)) {
    echo '<div class="msg-card success pb-banner">';

    echo '<div class="msg-header">Publicado en Plugin Store</div>';

    echo '<div class="msg-body">';

    echo 'Plugin: <strong>' . h((string) ($pub['id'] ?? '')) . '</strong> - Version: <strong>' . h((string) ($pub['version'] ?? '')) . '</strong><br>';

    echo 'SHA256: <code>' . h((string) ($pub['sha256'] ?? '')) . '</code><br>';
    echo (!empty($_SESSION['pb_last_publish_sync'])
      ? 'Version local sincronizada en <code>plugin.json</code>, <code>app.json</code>, <code>README</code> e instaladores.<br>'
      : 'Aviso: la publicacion se hizo, pero no se pudo confirmar la sincronizacion completa de la version local.<br>');
    if (!empty($_SESSION['pb_last_publish_registry_sync'])) {
      echo 'Registro local de plugins actualizado con la version y SHA publicados.<br>';
    }
    echo '<a class="btn-mini" target="_blank" rel="noopener" href="https://sede.csifsevilla.es/plugin-store/">Abrir Plugin Store</a>';
    echo '</div></div>';
    unset($_SESSION['pb_last_publish']);
    unset($_SESSION['pb_last_publish_sync']);
    unset($_SESSION['pb_last_publish_registry_sync']);
  }

  $localConversion = $_SESSION['pb_last_local_conversion'] ?? null;
  if (is_array($localConversion)) {
    echo '<div class="msg-card success pb-banner">';
    echo '<div class="msg-header">App convertida a plugin</div>';
    echo '<div class="msg-body">';
    echo 'App origen: <strong>' . h((string) ($localConversion['source_app'] ?? '')) . '</strong><br>';
    echo 'Plugin local: <strong>' . h((string) ($localConversion['plugin_name'] ?? $localConversion['plugin_id'] ?? '')) . '</strong><br>';
    echo h((string) ($localConversion['msg'] ?? 'La app ya se ejecuta con formato plugin.'));
    echo '</div></div>';
    unset($_SESSION['pb_last_local_conversion']);
  }
  ?>


  <div class="card card-table pb-builder-card">

    <form method="post" class="pb-form">
      <input type="hidden" name="csrf" value="<?php echo h($csrf); ?>">

      <div class="pb-wizard">
        <div class="pb-wizard-progress" id="pb-wizard-progress">
          <button type="button" class="pb-wizard-pill is-active" data-step-jump="0">
            <span class="pb-step-kicker">Paso 1</span>
            <strong>Origen</strong>
            <span>App base y conversion inicial</span>
          </button>
          <button type="button" class="pb-wizard-pill" data-step-jump="1">
            <span class="pb-step-kicker">Paso 2</span>
            <strong>Base de Datos</strong>
            <span>SQL y objetos detectados</span>
          </button>
          <button type="button" class="pb-wizard-pill" data-step-jump="2">
            <span class="pb-step-kicker">Paso 3</span>
            <strong>Tareas</strong>
            <span>Cron, batch y analisis</span>
          </button>
          <button type="button" class="pb-wizard-pill" data-step-jump="3">
            <span class="pb-step-kicker">Paso 4</span>
            <strong>Metadata</strong>
            <span>Nombre, roles y versionado</span>
          </button>
          <button type="button" class="pb-wizard-pill" data-step-jump="4">
            <span class="pb-step-kicker">Paso 5</span>
            <strong>Resumen</strong>
            <span>Revision final y accion</span>
          </button>
        </div>

        <section class="pb-step is-active" data-step-index="0">
          <div class="pb-step-head">
            <div>
              <div class="pb-step-kicker">Paso 1</div>
              <h3 style="margin:4px 0 0;">App origen y conversion</h3>
            </div>
            <div class="pb-note">Selecciona la app y decide su identidad de plugin.</div>
          </div>

      <div class="pb-field-block">
        <div class="hint">App origen</div>
        <select name="source_app" class="form-control ui-input-pad"
          onchange="location.href='?app='+encodeURIComponent(this.value)">
          <option value="">-- elige --</option>

          <?php foreach ($apps as $a): ?>

            <option value="<?php echo h($a); ?>" <?php echo ($a === $prefApp) ? 'selected' : ''; ?>>

              <?php echo h($a); ?>

            </option>

          <?php endforeach; ?>
        </select>
      </div>

      <?php if ($prefNeedsPluginConversion): ?>
        <div class="pb-convert-note">
          <strong>Primera conversion a plugin detectada.</strong><br>
          Al finalizar el proceso elegido, el Builder apartara temporalmente <code>/apps/<?php echo h($prefApp); ?></code>, instalara el ZIP generado como plugin local y, si todo sale bien, la app quedara servida desde <code>/plugins</code>.
        </div>
      <?php endif; ?>

      <div class="pb-step-actions pb-step-actions--end">
        <button type="button" class="btn-primary-intranet" data-step-next>Siguiente</button>
      </div>
        </section>

        <section class="pb-step" data-step-index="1">
          <div class="pb-step-head">
            <div>
              <div class="pb-step-kicker">Paso 2</div>
              <h3 style="margin:4px 0 0;">Base de datos y estructura</h3>
            </div>
            <div class="pb-note">Revisa los SQL incluidos y las tablas o vistas que el Builder ha detectado.</div>
          </div>

      <div class="pb-review-card">
        <div class="pb-sql-head">
          <div>
            <div class="hint" style="margin-bottom:4px;">Fechas de referencia</div>
            <div class="hint">Comparativa rapida entre el plugin local seleccionado y la version publicada en el Plugin Store. La fecha local es la marca del fichero en disco y puede reflejar una copia o reinstalacion.</div>
          </div>
          <div class="ui-count-badge"><?php echo $prefPluginId !== '' ? h($prefPluginId) : 'Sin plugin'; ?></div>
        </div>
        <div class="pb-review-grid">
          <div class="pb-review-item">
            <div class="hint">PHP local mas reciente</div>
            <strong><?php echo h(pb_format_local_datetime((int) ($prefLocalPhpInfo['mtime'] ?? 0))); ?></strong>
            <span class="hint">
              <?php if ($prefLocalPluginDir === null): ?>
                No se ha localizado un plugin local para esta app.
              <?php elseif ((int) ($prefLocalPhpInfo['count'] ?? 0) <= 0): ?>
                No hay ficheros PHP dentro del plugin local.
              <?php else: ?>
                <?php echo (int) ($prefLocalPhpInfo['count'] ?? 0); ?> PHP revisado(s).
                Ultimo: <code><?php echo h((string) ($prefLocalPhpInfo['path'] ?? '')); ?></code>
              <?php endif; ?>
            </span>
          </div>

          <div class="pb-review-item">
            <div class="hint">Plugin Store</div>
            <strong><?php echo h(pb_format_store_datetime($prefStorePublishedAt)); ?></strong>
            <span class="hint">
              <?php if (is_array($prefStorePlugin)): ?>
                Version publicada: <code><?php echo h($prefStoreVersion !== '' ? $prefStoreVersion : 'sin version'); ?></code>
              <?php else: ?>
                No se ha encontrado este plugin en el Store.
                <?php if (!empty($prefStoreErrors)): ?>
                  <?php echo h((string) $prefStoreErrors[0]); ?>
                <?php endif; ?>
              <?php endif; ?>
            </span>
          </div>
        </div>
      </div>

      <div class="pb-sql-box ui-surface-soft">
        <div class="pb-sql-head">
          <div>
            <div class="hint" style="margin-bottom:4px;">Scripts SQL detectados</div>

            <div class="hint">

              Se incluiran en el plugin generado. Puedes ordenar su ejecucion y marcar si el instalador debe crear o actualizar tablas/vistas automaticamente, o solo mostrar el SQL para ejecucion manual.

            </div>

          </div>

          <div class="ui-count-badge"><?php echo count($prefSqlScripts); ?> script(s)</div>

        </div>



        <?php if (empty($prefSqlScripts)): ?>

          <div class="pb-inline-note">No se han detectado ficheros <code>.sql</code> en la app seleccionada.</div>

        <?php else: ?>

          <div class="pb-sql-list">

            <?php foreach ($prefSqlScripts as $sql): ?>

              <?php $sqlFile = (string) ($sql['file'] ?? ''); ?>

              <div class="pb-sql-row">

                <label class="pb-sql-toggle">

                  <input type="checkbox" name="sql_enabled[<?php echo h($sqlFile); ?>]" value="1" <?php echo !empty($sql['enabled']) ? 'checked' : ''; ?>>

                  <span>

                    <strong><?php echo h($sqlFile); ?></strong><br>

                    <span class="hint">Incluido en el manifiesto del instalador</span>

                  </span>

                </label>



                <div>

                  <div class="hint" style="margin-bottom:4px;">Orden</div>

                  <input

                    type="number"

                    name="sql_order[<?php echo h($sqlFile); ?>]"

                    class="form-control ui-input-pad"

                    value="<?php echo h((string) ($sql['order'] ?? 50)); ?>"

                  >

                </div>



                <div>

                  <div class="hint" style="margin-bottom:4px;">Fase</div>

                  <select name="sql_stage[<?php echo h($sqlFile); ?>]" class="form-control ui-input-pad">

                    <?php foreach (['install' => 'Instalacion', 'migrate' => 'Migracion', 'update' => 'Actualizacion', 'cleanup' => 'Limpieza'] as $value => $label): ?>

                      <option value="<?php echo h($value); ?>" <?php echo (($sql['stage'] ?? '') === $value) ? 'selected' : ''; ?>><?php echo h($label); ?></option>

                    <?php endforeach; ?>

                  </select>

                </div>



                <div>

                  <div class="hint" style="margin-bottom:4px;">Modo</div>

                  <select name="sql_mode[<?php echo h($sqlFile); ?>]" class="form-control ui-input-pad">

                    <?php foreach (['auto' => 'Ejecutar', 'manual' => 'Mostrar'] as $value => $label): ?>

                      <option value="<?php echo h($value); ?>" <?php echo (($sql['mode'] ?? '') === $value) ? 'selected' : ''; ?>><?php echo h($label); ?></option>

                    <?php endforeach; ?>

                  </select>

                </div>



                <div class="pb-sql-state <?php echo !empty($sql['enabled']) ? '' : 'pb-sql-state--off'; ?>">

                  <?php echo (!empty($sql['enabled']) ? 'Activo' : 'Excluido'); ?>

                </div>

              </div>

            <?php endforeach; ?>

          </div>

        <?php endif; ?>
      </div>

      <div class="pb-step-actions">
        <button type="button" class="btn-secondary" data-step-prev>Anterior</button>
        <button type="button" class="btn-primary-intranet" data-step-next>Siguiente</button>
      </div>
        </section>

        <section class="pb-step" data-step-index="2">
          <div class="pb-step-head">
            <div>
              <div class="pb-step-kicker">Paso 3</div>
              <h3 style="margin:4px 0 0;">Tareas y análisis</h3>
            </div>
            <div class="pb-note">Configura cron, workers y revisa los objetos de base de datos detectados en la app.</div>
          </div>

      <div class="pb-cron-box ui-surface-soft">
        <div class="pb-sql-head">
          <div>
            <div class="hint" style="margin-bottom:4px;">Tareas programadas / cron</div>
            <div class="hint">
              Define scripts que deban ejecutarse por cron. En modo manual el instalador mostrara el comando y el codigo para que el administrador lo copie. En modo gestionado el instalador desplegara el script en <code>/var/cron/&lt;plugin&gt;/</code> con bloqueo HTTP y mostrara la linea cron final.
            </div>
          </div>
          <div class="ui-count-badge"><?php echo count($prefScheduledTasks); ?> tarea(s)</div>
        </div>

        <?php if (!empty($prefTaskCandidates)): ?>
          <div class="pb-inline-note">
            Scripts candidatos detectados:
            <?php echo h(implode(', ', array_slice($prefTaskCandidates, 0, 8))); ?>
            <?php if (count($prefTaskCandidates) > 8): ?>
              ...
            <?php endif; ?>
          </div>
        <?php endif; ?>

        <datalist id="pb-task-script-list">
          <?php foreach ($prefTaskCandidates as $candidate): ?>
            <option value="<?php echo h($candidate); ?>"></option>
          <?php endforeach; ?>
        </datalist>

        <?php $pbTaskRows = !empty($prefScheduledTasks) ? $prefScheduledTasks : [[
          'enabled' => 1,
          'name' => '',
          'script' => '',
          'schedule' => '',
          'mode' => 'manual',
          'php_bin' => '/usr/bin/php',
          'args' => '',
          'managed_path' => '',
          'description' => '',
        ]]; ?>
        <div class="pb-cron-list" id="pb-task-list">
          <?php foreach ($pbTaskRows as $task): ?>
            <div class="pb-cron-row" data-task-row>
              <div class="pb-cron-top">
                <label class="pb-cron-toggle">
                  <input type="checkbox" name="task_enabled[]" value="1" <?php echo !empty($task['enabled']) ? 'checked' : ''; ?>>
                  <span>Incluir tarea</span>
                </label>
                <button type="button" class="btn-secondary pb-cron-remove" data-task-remove>Quitar</button>
              </div>

              <div class="pb-cron-grid">
                <div class="pb-field-block">
                  <div class="hint">Nombre</div>
                  <input name="task_name[]" class="form-control ui-input-pad" value="<?php echo h((string) ($task['name'] ?? '')); ?>" placeholder="Envio de cartas">
                </div>
                <div class="pb-field-block">
                  <div class="hint">Expresion cron</div>
                  <input name="task_schedule[]" class="form-control ui-input-pad" value="<?php echo h((string) ($task['schedule'] ?? '')); ?>" placeholder="*/5 * * * *">
                </div>
              </div>

              <div class="pb-cron-grid">
                <div class="pb-field-block">
                  <div class="hint">Script relativo a la app</div>
                  <?php if (!empty($prefTaskCandidates)): ?>
                    <select class="form-control ui-input-pad" data-task-script-picker>
                      <option value="">Seleccionar script detectado...</option>
                      <?php foreach ($prefTaskCandidates as $candidate): ?>
                        <option value="<?php echo h($candidate); ?>" <?php echo ((string) ($task['script'] ?? '') === $candidate) ? 'selected' : ''; ?>>
                          <?php echo h($candidate); ?>
                        </option>
                      <?php endforeach; ?>
                    </select>
                  <?php endif; ?>
                  <input name="task_script[]" list="pb-task-script-list" class="form-control ui-input-pad" value="<?php echo h((string) ($task['script'] ?? '')); ?>" placeholder="worker/process_jobs.php">
                </div>
                <div class="pb-field-block">
                  <div class="hint">Modo</div>
                  <select name="task_mode[]" class="form-control ui-input-pad">
                    <option value="manual" <?php echo (($task['mode'] ?? 'manual') === 'manual') ? 'selected' : ''; ?>>Manual</option>
                    <option value="managed" <?php echo (($task['mode'] ?? '') === 'managed') ? 'selected' : ''; ?>>Gestionado</option>
                  </select>
                </div>
              </div>

              <div class="pb-cron-grid pb-cron-grid--wide">
                <div class="pb-field-block">
                  <div class="hint">PHP CLI</div>
                  <input name="task_php_bin[]" class="form-control ui-input-pad" value="<?php echo h((string) ($task['php_bin'] ?? '/usr/bin/php')); ?>" placeholder="/usr/bin/php">
                </div>
                <div class="pb-field-block">
                  <div class="hint">Argumentos opcionales</div>
                  <input name="task_args[]" class="form-control ui-input-pad" value="<?php echo h((string) ($task['args'] ?? '')); ?>" placeholder="--force --batch=50">
                </div>
                <div class="pb-field-block">
                  <div class="hint">Ruta privada destino</div>
                  <input name="task_managed_path[]" class="form-control ui-input-pad" value="<?php echo h((string) ($task['managed_path'] ?? '')); ?>" placeholder="cartas/process_jobs.php">
                </div>
              </div>

              <div class="pb-field-block">
                <div class="hint">Descripcion</div>
                <textarea name="task_description[]" class="form-control ui-input-pad" placeholder="Que hace esta tarea y para que debe programarse."><?php echo h((string) ($task['description'] ?? '')); ?></textarea>
              </div>
            </div>
          <?php endforeach; ?>
        </div>

        <div class="pb-cron-actions">
          <div class="pb-inline-note">Si dejas vacia una fila, no se incluira en el plugin.</div>
          <button type="button" class="btn-secondary" id="pb-task-add">Agregar tarea</button>
        </div>

        <template id="pb-task-template">
          <div class="pb-cron-row" data-task-row>
            <div class="pb-cron-top">
              <label class="pb-cron-toggle">
                <input type="checkbox" name="task_enabled[]" value="1" checked>
                <span>Incluir tarea</span>
              </label>
              <button type="button" class="btn-secondary pb-cron-remove" data-task-remove>Quitar</button>
            </div>

            <div class="pb-cron-grid">
              <div class="pb-field-block">
                <div class="hint">Nombre</div>
                <input name="task_name[]" class="form-control ui-input-pad" value="" placeholder="Envio de cartas">
              </div>
              <div class="pb-field-block">
                <div class="hint">Expresion cron</div>
                <input name="task_schedule[]" class="form-control ui-input-pad" value="" placeholder="*/5 * * * *">
              </div>
            </div>

            <div class="pb-cron-grid">
              <div class="pb-field-block">
                <div class="hint">Script relativo a la app</div>
                <?php if (!empty($prefTaskCandidates)): ?>
                  <select class="form-control ui-input-pad" data-task-script-picker>
                    <option value="">Seleccionar script detectado...</option>
                    <?php foreach ($prefTaskCandidates as $candidate): ?>
                      <option value="<?php echo h($candidate); ?>"><?php echo h($candidate); ?></option>
                    <?php endforeach; ?>
                  </select>
                <?php endif; ?>
                <input name="task_script[]" list="pb-task-script-list" class="form-control ui-input-pad" value="" placeholder="worker/process_jobs.php">
              </div>
              <div class="pb-field-block">
                <div class="hint">Modo</div>
                <select name="task_mode[]" class="form-control ui-input-pad">
                  <option value="manual" selected>Manual</option>
                  <option value="managed">Gestionado</option>
                </select>
              </div>
            </div>

            <div class="pb-cron-grid pb-cron-grid--wide">
              <div class="pb-field-block">
                <div class="hint">PHP CLI</div>
                <input name="task_php_bin[]" class="form-control ui-input-pad" value="/usr/bin/php" placeholder="/usr/bin/php">
              </div>
              <div class="pb-field-block">
                <div class="hint">Argumentos opcionales</div>
                <input name="task_args[]" class="form-control ui-input-pad" value="" placeholder="--force --batch=50">
              </div>
              <div class="pb-field-block">
                <div class="hint">Ruta privada destino</div>
                <input name="task_managed_path[]" class="form-control ui-input-pad" value="" placeholder="cartas/process_jobs.php">
              </div>
            </div>

            <div class="pb-field-block">
              <div class="hint">Descripcion</div>
              <textarea name="task_description[]" class="form-control ui-input-pad" placeholder="Que hace esta tarea y para que debe programarse."></textarea>
            </div>
          </div>
        </template>
      </div>

      <div class="pb-cron-box ui-surface-soft">
        <div class="pb-sql-head">
          <div>
            <div class="hint" style="margin-bottom:4px;">Scripts auxiliares</div>
            <div class="hint">
              Incluye scripts manuales en el ZIP del plugin, por ejemplo agentes PowerShell, scripts de despliegue GPO o utilidades de administracion. El instalador los documentara, pero no los ejecutara automaticamente.
            </div>
          </div>
          <div class="ui-count-badge"><?php echo count($prefSupportScripts); ?> script(s)</div>
        </div>

        <?php if (!empty($prefSupportScriptCandidates)): ?>
          <div class="pb-inline-note">
            Scripts disponibles:
            <?php echo h(implode(', ', array_slice($prefSupportScriptCandidates, 0, 8))); ?>
            <?php if (count($prefSupportScriptCandidates) > 8): ?>
              ...
            <?php endif; ?>
          </div>
        <?php endif; ?>

        <datalist id="pb-support-script-list">
          <?php foreach ($prefSupportScriptCandidates as $candidate): ?>
            <option value="<?php echo h($candidate); ?>"></option>
          <?php endforeach; ?>
        </datalist>

        <?php $pbSupportRows = !empty($prefSupportScripts) ? $prefSupportScripts : [[
          'source_path' => '',
          'package_path' => '',
          'description' => '',
          'instructions' => '',
        ]]; ?>
        <div class="pb-cron-list" id="pb-support-list">
          <?php foreach ($pbSupportRows as $support): ?>
            <div class="pb-cron-row" data-support-row>
              <div class="pb-cron-top">
                <strong>Script auxiliar</strong>
                <button type="button" class="btn-secondary pb-cron-remove" data-support-remove>Quitar</button>
              </div>

              <div class="pb-cron-grid">
                <div class="pb-field-block">
                  <div class="hint">Ruta origen en la intranet</div>
                  <?php if (!empty($prefSupportScriptCandidates)): ?>
                    <select class="form-control ui-input-pad" data-support-source-picker>
                      <option value="">Seleccionar script detectado...</option>
                      <?php foreach ($prefSupportScriptCandidates as $candidate): ?>
                        <option value="<?php echo h($candidate); ?>" <?php echo ((string) ($support['source_path'] ?? '') === $candidate) ? 'selected' : ''; ?>>
                          <?php echo h($candidate); ?>
                        </option>
                      <?php endforeach; ?>
                    </select>
                  <?php endif; ?>
                  <input name="support_source_path[]" list="pb-support-script-list" class="form-control ui-input-pad" value="<?php echo h((string) ($support['source_path'] ?? '')); ?>" placeholder="scripts/inventtory.ps1">
                </div>
                <div class="pb-field-block">
                  <div class="hint">Ruta dentro del plugin</div>
                  <input name="support_package_path[]" class="form-control ui-input-pad" value="<?php echo h((string) ($support['package_path'] ?? '')); ?>" placeholder="scripts/inventtory.ps1">
                </div>
              </div>

              <div class="pb-field-block">
                <div class="hint">Que hace</div>
                <textarea name="support_description[]" class="form-control ui-input-pad" placeholder="Recoge inventario del equipo y lo envia a la API de la intranet."><?php echo h((string) ($support['description'] ?? '')); ?></textarea>
              </div>

              <div class="pb-field-block">
                <div class="hint">Como ejecutarlo</div>
                <textarea name="support_instructions[]" class="form-control ui-input-pad" placeholder="Ejecutar en PowerShell 5.1 como administrador: powershell.exe -ExecutionPolicy Bypass -File .\scripts\inventtory.ps1"><?php echo h((string) ($support['instructions'] ?? '')); ?></textarea>
              </div>
            </div>
          <?php endforeach; ?>
        </div>

        <div class="pb-cron-actions">
          <div class="pb-inline-note">Si dejas vacia una fila, no se incluira en el plugin.</div>
          <button type="button" class="btn-secondary" id="pb-support-add">Agregar script</button>
        </div>

        <template id="pb-support-template">
          <div class="pb-cron-row" data-support-row>
            <div class="pb-cron-top">
              <strong>Script auxiliar</strong>
              <button type="button" class="btn-secondary pb-cron-remove" data-support-remove>Quitar</button>
            </div>

            <div class="pb-cron-grid">
              <div class="pb-field-block">
                <div class="hint">Ruta origen en la intranet</div>
                <?php if (!empty($prefSupportScriptCandidates)): ?>
                  <select class="form-control ui-input-pad" data-support-source-picker>
                    <option value="">Seleccionar script detectado...</option>
                    <?php foreach ($prefSupportScriptCandidates as $candidate): ?>
                      <option value="<?php echo h($candidate); ?>"><?php echo h($candidate); ?></option>
                    <?php endforeach; ?>
                  </select>
                <?php endif; ?>
                <input name="support_source_path[]" list="pb-support-script-list" class="form-control ui-input-pad" value="" placeholder="scripts/inventtory.ps1">
              </div>
              <div class="pb-field-block">
                <div class="hint">Ruta dentro del plugin</div>
                <input name="support_package_path[]" class="form-control ui-input-pad" value="" placeholder="scripts/inventtory.ps1">
              </div>
            </div>

            <div class="pb-field-block">
              <div class="hint">Que hace</div>
              <textarea name="support_description[]" class="form-control ui-input-pad" placeholder="Recoge inventario del equipo y lo envia a la API de la intranet."></textarea>
            </div>

            <div class="pb-field-block">
              <div class="hint">Como ejecutarlo</div>
              <textarea name="support_instructions[]" class="form-control ui-input-pad" placeholder="Ejecutar en PowerShell 5.1 como administrador: powershell.exe -ExecutionPolicy Bypass -File .\scripts\inventtory.ps1"></textarea>
            </div>
          </div>
        </template>
      </div>

      <div class="pb-db-box ui-surface-soft">
        <div class="pb-sql-head">

          <div>

            <div class="hint" style="margin-bottom:4px;">Tablas y vistas detectadas en la app</div>
            <div class="hint">
              Deteccion heuristica a partir de ficheros <code>.sql</code> y consultas encontradas en el codigo.
              Si el objeto existe en la base de datos actual, se muestra su estructura real desde <code>INFORMATION_SCHEMA</code>; si lo crea un SQL del plugin, se marca como cubierto por SQL.
            </div>
          </div>
          <div class="ui-count-badge"><?php echo count($prefDetectedTables); ?> objeto(s)</div>

        </div>



        <?php if (empty($prefDetectedTables)): ?>

          <div class="pb-inline-note">No se han detectado referencias claras a tablas o vistas en la app seleccionada.</div>

        <?php else: ?>

          <div class="pb-db-list">

            <?php $pbTableIndex = 0; ?>

            <?php foreach ($prefDetectedTables as $tableName => $tableMeta): ?>

              <?php

                $schema = $prefTableSchemas[$tableName] ?? ['exists' => false, 'engine' => '', 'comment' => '', 'columns' => []];

                $files = (array) ($tableMeta['files'] ?? []);
                $sources = (array) ($tableMeta['sources'] ?? []);
                $exists = !empty($schema['exists']);
                $operations = (array) ($tableMeta['operations'] ?? []);
                $plannedBySql = !$exists && !empty($tableMeta['sql_plan']);
                $statusClass = $exists ? '' : ($plannedBySql ? 'pb-db-status--planned' : 'pb-db-status--missing');
                $statusLabel = $exists ? 'En BD' : ($plannedBySql ? 'Cubierta por SQL' : 'No encontrada');
              ?>
              <details class="pb-db-card" <?php echo $pbTableIndex === 0 ? 'open' : ''; ?>>

                <summary class="pb-db-summary">

                  <div>

                    <strong><?php echo h($tableName); ?></strong><br>

                    <span class="hint">

                      <?php echo h((string) (($schema['object_type'] ?? '') ?: 'OBJETO')); ?>

                      ·

                      Detectada desde <?php echo h(implode(' + ', $sources ?: ['source'])); ?>

                      <?php if (!empty($schema['engine'])): ?>

                        · Engine: <?php echo h((string) $schema['engine']); ?>

                      <?php endif; ?>

                    </span>

                  </div>

                  <span class="pb-db-status <?php echo $statusClass; ?>">
                    <?php echo h($statusLabel); ?>
                  </span>
                </summary>
                <div class="pb-db-body">
                  <div class="pb-db-meta">
                    <?php echo count($files); ?> fichero(s) con referencia a esta tabla.
                    <?php if (!empty($operations)): ?>
                      Operaciones detectadas: <?php echo h(implode(', ', $operations)); ?>.
                    <?php endif; ?>
                    <?php if ($plannedBySql): ?>
                      No existe en la BD actual, pero el paquete incluye SQL de creacion/actualizacion para este objeto.
                    <?php endif; ?>
                    <?php if (!empty($schema['comment'])): ?>
                      Comentario: <?php echo h((string) $schema['comment']); ?>
                    <?php endif; ?>
                  </div>



                  <?php if (!empty($files)): ?>

                    <div class="pb-db-files">

                      <?php foreach ($files as $fileRef): ?>

                        <span class="pb-db-file"><?php echo h((string) $fileRef); ?></span>

                      <?php endforeach; ?>

                    </div>

                  <?php endif; ?>



                  <?php if (!empty($schema['columns'])): ?>

                    <div class="pb-db-table-wrap">

                      <table class="pb-db-table">

                        <thead>

                          <tr>

                            <th>Columna</th>

                            <th>Tipo</th>

                            <th>Null</th>

                            <th>Default</th>

                            <th>Clave</th>

                            <th>Extra</th>

                          </tr>

                        </thead>

                        <tbody>

                          <?php foreach ((array) $schema['columns'] as $column): ?>

                            <tr>

                              <td><code><?php echo h((string) ($column['name'] ?? '')); ?></code></td>

                              <td><?php echo h((string) ($column['type'] ?? '')); ?></td>

                              <td><?php echo h((string) ($column['nullable'] ?? '')); ?></td>

                              <td><?php echo h((string) (($column['default'] ?? null) === null ? 'NULL' : (string) $column['default'])); ?></td>

                              <td><?php echo h((string) ($column['key'] ?? '')); ?></td>

                              <td><?php echo h((string) ($column['extra'] ?? '')); ?></td>

                            </tr>

                          <?php endforeach; ?>

                        </tbody>

                      </table>

                    </div>

                  <?php else: ?>

                    <div class="pb-inline-note">

                      <?php echo $exists ? 'El objeto existe, pero no se han podido leer columnas.' : 'El objeto no existe en la base de datos actual o aun no ha sido instalado.'; ?>

                    </div>

                  <?php endif; ?>

                </div>

              </details>

              <?php $pbTableIndex++; ?>

            <?php endforeach; ?>

          </div>

        <?php endif; ?>
      </div>

      <div class="pb-step-actions">
        <button type="button" class="btn-secondary" data-step-prev>Anterior</button>
        <button type="button" class="btn-primary-intranet" data-step-next>Siguiente</button>
      </div>
        </section>

        <section class="pb-step" data-step-index="3">
          <div class="pb-step-head">
            <div>
              <div class="pb-step-kicker">Paso 4</div>
              <h3 style="margin:4px 0 0;">Metadata del plugin</h3>
            </div>
            <div class="pb-note">Completa nombre, versionado, carpeta, grupo, roles e icono.</div>
          </div>

      <div>
        <div class="hint" style="margin-bottom:6px;">Cambios de esta versión</div>
        <textarea id="pb_release_notes" name="release_notes" class="form-control ui-input-pad"
          style="min-height:110px;" placeholder="Una línea por cambio. Ejemplo:&#10;- Nuevo instalador&#10;- Corrección de permisos&#10;- Soporte para cron"><?php echo h($prefReleaseNotes); ?></textarea>
        <div class="pb-note" style="margin-top:8px;">
          <?php if ($prefExistingPlugin || !$prefNeedsPluginConversion): ?>
            Si esta salida genera una nueva versión de un plugin existente, este campo es obligatorio.
          <?php else: ?>
            En la primera publicación puedes dejarlo vacío, aunque conviene anotar lo más importante.
          <?php endif; ?>
        </div>
      </div>

      <div class="ui-grid-2">
        <div class="pb-field-block">

          <div class="hint">ID del plugin</div>

          <input name="plugin_id" class="form-control ui-input-pad"

            value="<?php echo h($prefPluginId); ?>">

        </div>

        <div class="pb-field-block">

          <div class="hint">app_folder</div>

          <input name="app_folder" class="form-control ui-input-pad"

            value="<?php echo h($prefAppFolder); ?>">

        </div>

      </div>



      <div class="ui-grid-3">

        <div class="pb-field-block">

          <div class="hint" style="margin-bottom:6px;">Nombre</div>

          <input name="name" class="form-control ui-input-pad" value="<?php echo h($prefName); ?>">

        </div>

        <div class="pb-field-block">

          <div class="hint" style="margin-bottom:6px;">Versión actual</div>

          <input id="pb_version" name="version" class="form-control ui-input-pad"

            value="<?php echo h($prefVersion); ?>">

        </div>

        <div class="pb-field-block">

          <div class="hint" style="margin-bottom:6px;">Nueva versión</div>

          <input id="pb_version_next" class="form-control ui-input-pad" value="" readonly>

        </div>

      </div>



      <div class="ui-grid-3wide">

        <div class="pb-field-block">

          <div class="hint" style="margin-bottom:6px;">Tipo de cambio</div>

          <select id="pb_bump" name="bump_type" class="form-control ui-input-pad">

            <option value="c" <?php echo $prefBumpType === 'c' ? 'selected' : ''; ?>>c (patch)</option>
            <option value="b" <?php echo $prefBumpType === 'b' ? 'selected' : ''; ?>>b (minor)</option>
            <option value="a" <?php echo $prefBumpType === 'a' ? 'selected' : ''; ?>>a (major)</option>
          </select>

        </div>

        <div class="pb-field-block">

          <div class="hint" style="margin-bottom:6px;">Auto-incremento</div>

          <label class="ui-check-card">

            <input id="pb_auto" type="checkbox" name="auto_bump" value="1" <?php echo $prefAutoBump ? 'checked' : ''; ?>>
            <span>Incrementar versión al ejecutar</span>

          </label>

        </div>

        <div style="align-self:end;" class="pb-note">

          a=Major, b=Minor, c=Patch.

        </div>

      </div>



      <div>

        <div class="hint" style="margin-bottom:6px;">Descripción</div>

        <textarea name="description" class="form-control ui-input-pad"

          style="min-height:90px;"><?php echo h($prefDesc); ?></textarea>

      </div>



      <div class="ui-grid-2">

        <div class="pb-field-block">
          <div class="hint">Icon</div>
          <input name="icon" class="form-control ui-input-pad" value="<?php echo h($prefIcon); ?>">
        </div>
        <div class="pb-field-block">
          <div class="hint">Grupo de menu</div>
          <select name="default_group_name" class="form-control ui-input-pad" required>
            <?php foreach ($appGroupOptions as $groupName): ?>
              <option value="<?php echo h($groupName); ?>" <?php echo $groupName === $prefGroup ? 'selected' : ''; ?>>
                <?php echo h($groupName); ?>
              </option>
            <?php endforeach; ?>
          </select>
        </div>
      </div>


      <div class="ui-grid-2">
        <div class="pb-field-block">
          <div class="hint">new_tab</div>
          <select name="new_tab" class="form-control ui-input-pad">
            <option value="0" <?php echo $prefNewTab ? '' : 'selected'; ?>>No</option>
            <option value="1" <?php echo $prefNewTab ? 'selected' : ''; ?>>Sí</option>
          </select>
        </div>
        <div class="pb-field-block">
          <div class="hint">Roles</div>
          <select name="roles_json[]" class="form-control ui-input-pad" multiple required style="min-height:116px;">
            <?php foreach ($availableRoles as $role): ?>
              <option value="<?php echo h($role); ?>" <?php echo in_array($role, $prefRoleList, true) ? 'selected' : ''; ?>>
                <?php echo h($role); ?>
              </option>
            <?php endforeach; ?>
          </select>
        </div>
      </div>

      <div class="pb-step-actions">
        <button type="button" class="btn-secondary" data-step-prev>Anterior</button>
        <button type="button" class="btn-primary-intranet" data-step-next>Siguiente</button>
      </div>
        </section>

        <section class="pb-step" data-step-index="4">
          <div class="pb-step-head">
            <div>
              <div class="pb-step-kicker">Paso 5</div>
              <h3 style="margin:4px 0 0;">Resumen y ejecución</h3>
            </div>
            <div class="pb-note">Revisa el plugin que se va a crear o actualizar antes de lanzar el proceso.</div>
          </div>

          <div class="pb-review-card">
            <div class="pb-review-grid">
              <div class="pb-review-item">
                <div class="hint">App origen</div>
                <code id="pb-review-source"><?php echo h($prefApp); ?></code>
              </div>
              <div class="pb-review-item">
                <div class="hint">Plugin</div>
                <code id="pb-review-plugin"><?php echo h($prefPluginId); ?></code>
              </div>
              <div class="pb-review-item">
                <div class="hint">Carpeta final</div>
                <code id="pb-review-folder"><?php echo h($prefAppFolder); ?></code>
              </div>
              <div class="pb-review-item">
                <div class="hint">Grupo de menu</div>
                <code id="pb-review-group"><?php echo h($prefGroup); ?></code>
              </div>
              <div class="pb-review-item">
                <div class="hint">Version resultante</div>
                <code id="pb-review-version"><?php echo h($prefVersion); ?></code>
              </div>
              <div class="pb-review-item">
                <div class="hint">SQL incluidos</div>
                <strong id="pb-review-sql-count"><?php echo count($prefSqlScripts); ?></strong>
              </div>
              <div class="pb-review-item">
                <div class="hint">Tareas cron</div>
                <strong id="pb-review-task-count"><?php echo count($prefScheduledTasks); ?></strong>
              </div>
              <div class="pb-review-item">
                <div class="hint">Scripts auxiliares</div>
                <strong id="pb-review-support-count"><?php echo count($prefSupportScripts); ?></strong>
              </div>
              <div class="pb-review-item">
                <div class="hint">Objetos detectados</div>
                <strong><?php echo count($prefDetectedTables); ?></strong>
              </div>
              <div class="pb-review-item">
                <div class="hint">Primera conversion local</div>
                <strong id="pb-review-convert"><?php echo $prefNeedsPluginConversion ? 'Si' : 'No'; ?></strong>
              </div>
            </div>

            <div class="pb-convert-note" style="margin-bottom:10px;">
              <strong>Cambios de la version</strong><br>
              <span id="pb-review-notes"><?php echo $prefReleaseNotes !== '' ? nl2br(h($prefReleaseNotes)) : 'Sin anotar'; ?></span>
            </div>

            <div class="pb-convert-note" id="pb-review-note">
              <?php if ($prefNeedsPluginConversion): ?>
                Se detecta una app aun ubicada en <code>/apps/<?php echo h($prefApp); ?></code>. Si el proceso termina bien, el Builder la sustituira por el plugin instalado localmente.
              <?php else: ?>
                Esta app ya parece estar gestionada como plugin o no necesita una conversion inicial.
              <?php endif; ?>
            </div>
          </div>

      <div class="ui-actions">
        <select name="do" class="form-control ui-input-pad pb-action-select">
          <option value="download" <?php echo $prefAction === 'download' ? 'selected' : ''; ?>>Generar y descargar</option>
          <option value="publish" <?php echo $prefAction === 'publish' ? 'selected' : ''; ?>>Generar y publicar en el Store</option>
          <option value="both" <?php echo $prefAction === 'both' ? 'selected' : ''; ?>>Generar, publicar y descargar</option>
        </select>

        <a class="btn-secondary pb-store-link" href="https://sede.csifsevilla.es/plugin-store/admin/" target="_blank" rel="noopener">
          Abrir Plugin Store (admin)
        </a>
      </div>

      <div class="pb-note">

        Al publicar, el builder compara el plugin generado con la versión actual del Store y bloquea publicaciones sin cambios reales aunque la versión se haya incrementado.

      </div>
      <div class="pb-step-actions">
        <button type="button" class="btn-secondary" data-step-prev>Anterior</button>
        <button class="btn-primary-intranet" type="submit">Ejecutar</button>
      </div>
        </section>
      </div>
    </form>
  </div>
  </div>



  <script>
    (function () {
      function semverNext(cur, bump) {
        var m = String(cur || '').trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
        var a = 1, b = 0, c = 0;
        if (m) { a = parseInt(m[1], 10); b = parseInt(m[2], 10); c = parseInt(m[3], 10); }

        bump = String(bump || 'c').toLowerCase();

        if (bump === 'a' || bump === 'major') { a++; b = 0; c = 0; }

        else if (bump === 'b' || bump === 'minor') { b++; c = 0; }

        else { c++; }
        return a + '.' + b + '.' + c;
      }

      var v = document.getElementById('pb_version');
      var n = document.getElementById('pb_version_next');
      var b = document.getElementById('pb_bump');
      var a = document.getElementById('pb_auto');
      var releaseNotes = document.getElementById('pb_release_notes');
      var needsConversion = <?php echo $prefNeedsPluginConversion ? 'true' : 'false'; ?>;
      var existingPlugin = <?php echo ($prefExistingPlugin || !$prefNeedsPluginConversion) ? 'true' : 'false'; ?>;

      function refresh() {
        if (v && n && b && a) {
          n.value = a.checked ? semverNext(v.value, b.value) : v.value;
        }
        refreshReview();
      }

      function countChecked(selector) {
        return Array.prototype.slice.call(document.querySelectorAll(selector)).filter(function (el) {
          return !!el.checked;
        }).length;
      }

      function countFilledRows(selector, fieldSelector) {
        return Array.prototype.slice.call(document.querySelectorAll(selector)).filter(function (row) {
          var field = row.querySelector(fieldSelector);
          return field && String(field.value || '').trim() !== '';
        }).length;
      }

      function setText(id, value) {
        var el = document.getElementById(id);
        if (el) {
          el.textContent = value;
        }
      }

      function refreshReview() {
        var source = document.querySelector('select[name="source_app"]');
        var plugin = document.querySelector('input[name="plugin_id"]');
        var folder = document.querySelector('input[name="app_folder"]');
        var group = document.querySelector('select[name="default_group_name"]');
        var action = document.querySelector('select[name="do"]');
        var sourceValue = source ? source.value : '';
        var convert = !!needsConversion;
        setText('pb-review-source', sourceValue || '-');
        setText('pb-review-plugin', plugin ? plugin.value || '-' : '-');
        setText('pb-review-folder', folder ? folder.value || '-' : '-');
        setText('pb-review-group', group ? group.value || '-' : '-');
        setText('pb-review-version', n ? n.value || (v ? v.value : '-') : (v ? v.value : '-'));
        setText('pb-review-sql-count', String(countChecked('input[name^="sql_enabled["]')));
        setText('pb-review-task-count', String(countFilledRows('[data-task-row]', 'input[name="task_script[]"]')));
        setText('pb-review-support-count', String(countFilledRows('[data-support-row]', 'input[name="support_source_path[]"]')));
        setText('pb-review-convert', convert ? 'Si' : 'No');
        var reviewNotes = document.getElementById('pb-review-notes');
        if (reviewNotes) {
          var notesText = releaseNotes ? String(releaseNotes.value || '').trim() : '';
          reviewNotes.innerHTML = notesText
            ? notesText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
            : 'Sin anotar';
        }

        var reviewNote = document.getElementById('pb-review-note');
        if (reviewNote) {
          if (convert) {
            reviewNote.innerHTML = 'Si esta app sigue en <code>/apps/' + (sourceValue || '') + '</code>, el Builder intentara convertirla a plugin local al terminar el proceso seleccionado.';
          } else {
            reviewNote.textContent = 'Selecciona una app para ver el resumen de conversión.';
          }
        }
      }

      var steps = Array.prototype.slice.call(document.querySelectorAll('[data-step-index]'));
      var stepPills = Array.prototype.slice.call(document.querySelectorAll('[data-step-jump]'));
      var currentStep = 0;

      function showStep(index) {
        if (!steps.length) return;
        currentStep = Math.max(0, Math.min(index, steps.length - 1));
        steps.forEach(function (step, idx) {
          step.classList.toggle('is-active', idx === currentStep);
        });
        stepPills.forEach(function (pill, idx) {
          pill.classList.toggle('is-active', idx === currentStep);
          pill.classList.toggle('is-done', idx < currentStep);
        });
        window.scrollTo({ top: 0, behavior: 'smooth' });
      }

      document.querySelectorAll('[data-step-next]').forEach(function (btn) {
        btn.addEventListener('click', function () {
          showStep(currentStep + 1);
        });
      });

      document.querySelectorAll('[data-step-prev]').forEach(function (btn) {
        btn.addEventListener('click', function () {
          showStep(currentStep - 1);
        });
      });

      stepPills.forEach(function (pill) {
        pill.addEventListener('click', function () {
          showStep(parseInt(pill.getAttribute('data-step-jump') || '0', 10) || 0);
        });
      });

      if (v) v.addEventListener('input', refresh);
      if (b) b.addEventListener('change', refresh);
      if (a) a.addEventListener('change', refresh);
      if (releaseNotes) {
        releaseNotes.addEventListener('input', refresh);
        releaseNotes.addEventListener('change', refresh);
      }
      document.querySelectorAll('input[name="plugin_id"], input[name="app_folder"], select[name="source_app"], select[name="default_group_name"], input[name="task_script[]"], input[name="task_schedule[]"], input[name="support_source_path[]"], input[name^="sql_enabled["], select[name="do"]').forEach(function (el) {
        el.addEventListener('input', refresh);
        el.addEventListener('change', refresh);
      });

      refresh();

      var taskList = document.getElementById('pb-task-list');
      var taskTemplate = document.getElementById('pb-task-template');
      var taskAdd = document.getElementById('pb-task-add');

      function bindTaskRow(row) {
        if (!row) return;
        row.querySelectorAll('input, select, textarea').forEach(function (el) {
          el.addEventListener('input', refresh);
          el.addEventListener('change', refresh);
        });
        var scriptPicker = row.querySelector('[data-task-script-picker]');
        if (scriptPicker) {
          scriptPicker.addEventListener('change', function () {
            var target = row.querySelector('input[name="task_script[]"]');
            if (target && scriptPicker.value) {
              target.value = scriptPicker.value;
            }
            refresh();
          });
        }
        var remove = row.querySelector('[data-task-remove]');
        if (remove) {
          remove.addEventListener('click', function () {
            var rows = taskList ? taskList.querySelectorAll('[data-task-row]') : [];
            if (rows.length <= 1) {
              row.querySelectorAll('input:not([type]), input[type="text"], textarea').forEach(function (el) { el.value = ''; });
              row.querySelectorAll('select').forEach(function (el) { el.value = el.name === 'task_mode[]' ? 'manual' : el.value; });
              row.querySelectorAll('[data-task-script-picker]').forEach(function (el) { el.value = ''; });
              var enabled = row.querySelector('input[name="task_enabled[]"]');
              if (enabled) enabled.checked = true;
              refresh();
              return;
            }
            row.remove();
            refresh();
          });
        }
      }

      if (taskList) {
        taskList.querySelectorAll('[data-task-row]').forEach(bindTaskRow);
      }

      if (taskAdd && taskList && taskTemplate) {
        taskAdd.addEventListener('click', function () {
          var fragment = taskTemplate.content.cloneNode(true);
          taskList.appendChild(fragment);
          bindTaskRow(taskList.lastElementChild);
          refresh();
        });
      }

      var supportList = document.getElementById('pb-support-list');
      var supportTemplate = document.getElementById('pb-support-template');
      var supportAdd = document.getElementById('pb-support-add');

      function defaultSupportPackagePath(sourcePath) {
        var name = String(sourcePath || '').replace(/\\/g, '/').split('/').pop() || '';
        name = name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '');
        return name ? 'scripts/' + name : '';
      }

      function bindSupportRow(row) {
        if (!row) return;
        row.querySelectorAll('input, select, textarea').forEach(function (el) {
          el.addEventListener('input', refresh);
          el.addEventListener('change', refresh);
        });
        var sourcePicker = row.querySelector('[data-support-source-picker]');
        if (sourcePicker) {
          sourcePicker.addEventListener('change', function () {
            var sourceInput = row.querySelector('input[name="support_source_path[]"]');
            var packageInput = row.querySelector('input[name="support_package_path[]"]');
            if (sourceInput && sourcePicker.value) {
              sourceInput.value = sourcePicker.value;
              if (packageInput && String(packageInput.value || '').trim() === '') {
                packageInput.value = defaultSupportPackagePath(sourcePicker.value);
              }
            }
            refresh();
          });
        }
        var remove = row.querySelector('[data-support-remove]');
        if (remove) {
          remove.addEventListener('click', function () {
            var rows = supportList ? supportList.querySelectorAll('[data-support-row]') : [];
            if (rows.length <= 1) {
              row.querySelectorAll('input, textarea').forEach(function (el) { el.value = ''; });
              row.querySelectorAll('[data-support-source-picker]').forEach(function (el) { el.value = ''; });
              refresh();
              return;
            }
            row.remove();
            refresh();
          });
        }
      }

      if (supportList) {
        supportList.querySelectorAll('[data-support-row]').forEach(bindSupportRow);
      }

      if (supportAdd && supportList && supportTemplate) {
        supportAdd.addEventListener('click', function () {
          var fragment = supportTemplate.content.cloneNode(true);
          supportList.appendChild(fragment);
          bindSupportRow(supportList.lastElementChild);
          refresh();
        });
      }

      var form = document.querySelector('.pb-form-shell form');
      if (form) {
        form.addEventListener('submit', function (event) {
          var currentVersion = v ? String(v.value || '').trim() : '';
          var nextVersion = n ? String(n.value || '').trim() : currentVersion;
          var notesText = releaseNotes ? String(releaseNotes.value || '').trim() : '';
          if (existingPlugin && nextVersion !== '' && nextVersion !== currentVersion && notesText === '') {
            event.preventDefault();
            alert('Debes anotar los cambios de la nueva versión antes de ejecutar el Plugin Builder.');
            if (releaseNotes) {
              releaseNotes.focus();
            }
          }
        });
      }

      showStep(0);
    })();
  </script>
</body>



</html>

