<?php
namespace GlpiPlugin\Wbstore;

if (!defined('GLPI_ROOT')) {
   die("Sorry. You can't access this file directly");
}

// Hard requires (so other plugins can include guard without relying on WBStore autoloader)
require_once __DIR__ . '/../src/Config.php';
require_once __DIR__ . '/../src/Utils.php';
require_once __DIR__ . '/../src/StoreClient.php';
require_once __DIR__ . '/../src/License.php';
require_once __DIR__ . '/../src/Webhook.php';

class Guard {

   
   // ===== Cache local de pacotes/liberações (DB Config) =====
   private const PACKAGES_CACHE_KEY_JSON        = 'packages_cache_json';
   private const PACKAGES_CACHE_KEY_UNTIL       = 'packages_cache_until';
   private const PACKAGES_CACHE_KEY_LAST_OK_AT  = 'packages_cache_last_online_ok_at';
   private const PACKAGES_CACHE_KEY_HMAC        = 'packages_cache_hmac';
   private const PACKAGES_CACHE_KEY_LAST_TRY_AT = 'packages_cache_last_try_at';

   // Cache completo (catálogo) em arquivo + HMAC/TTL no DB
   private const PACKAGES_FULL_CACHE_KEY_HMAC  = 'packages_full_cache_hmac';
   private const PACKAGES_FULL_CACHE_KEY_UNTIL = 'packages_full_cache_until';

   /** Caminho do arquivo de cache (files/_cache/wbstore) */
   private static function cacheFile(string $name): string {
      $base = defined('GLPI_VAR_DIR') ? GLPI_VAR_DIR : (rtrim(GLPI_ROOT, '/\\') . '/files');
      $dir  = rtrim($base, '/\\') . '/_cache/wbstore';
      Utils::ensureDir($dir);
      return $dir . '/' . $name;
   }

   private static function clearPackagesCache(string $reason = ''): void {
      try {
         Config::set([
            self::PACKAGES_CACHE_KEY_JSON       => '',
            self::PACKAGES_CACHE_KEY_HMAC       => '',
            self::PACKAGES_CACHE_KEY_UNTIL      => 0,
            self::PACKAGES_CACHE_KEY_LAST_OK_AT => 0,
         ]);
         // FULL cache
         $file = self::cacheFile('packages_full_cache.json');
         @unlink($file);
         Config::set([
            self::PACKAGES_FULL_CACHE_KEY_HMAC   => '',
            self::PACKAGES_FULL_CACHE_KEY_UNTIL  => 0,
         ]);

         if ($reason !== '' && class_exists('Toolbox') && method_exists('Toolbox','logInFile')) {
            \Toolbox::logInFile('wbstore_super', '[wbstore] clearPackagesCache | ' . $reason . "\n");
         }
      } catch (\Throwable $e) {}
   }

   private static function isDenyError(int $code): bool {
      return ($code >= 400 && $code < 500 && $code != 429);
   }


   /**
    * Cache completo para o catálogo (descrições, etc.).
    * Preferência: arquivo assinado. Fallback: cache legado no DB.
    */
   public static function getFullPackagesCache(bool $allowStale = true): array {
      $until = (int)Config::get(self::PACKAGES_FULL_CACHE_KEY_UNTIL, 0);
      if (!$allowStale && $until > 0 && $until < time()) {
         return [];
      }

      $file = self::cacheFile('packages_full_cache.json');
      $json = '';
      if (is_file($file)) {
         $json = (string)@file_get_contents($file);
      }
      $sig = (string)Config::get(self::PACKAGES_FULL_CACHE_KEY_HMAC, '');
      if ($json !== '' && $sig !== '' && hash_equals($sig, Utils::hmac($json))) {
         $data = Utils::jsonDecode($json);
         return is_array($data) ? $data : [];
      }

      // Fallback: versões antigas guardavam o JSON full no DB (packages_cache_json)
      $legacyJson = (string)Config::get(self::PACKAGES_CACHE_KEY_JSON, '');
      $legacySig  = (string)Config::get(self::PACKAGES_CACHE_KEY_HMAC, '');
      if ($legacyJson !== '' && $legacySig !== '' && hash_equals($legacySig, Utils::hmac($legacyJson))) {
         $data = Utils::jsonDecode($legacyJson);
         // Só considera full se vier com 'packages' (estrutura do portal)
         if (is_array($data) && isset($data['packages'])) {
            return $data;
         }
      }
      return [];
   }

   /**
    * Carrega cache de pacotes do Config DB e valida assinatura.
    * Retorna lista de pacotes (array) ou [].
    */
   private static function getPackagesCache(bool $allowStale = true): array {
      $json = (string)Config::get(self::PACKAGES_CACHE_KEY_JSON, '');
      if ($json === '') {
         return [];
      }
      $sig  = (string)Config::get(self::PACKAGES_CACHE_KEY_HMAC, '');
      if ($sig === '' || $sig !== Utils::hmac($json)) {
         // cache adulterado ou inválido
         return [];
      }
      $until = (int)Config::get(self::PACKAGES_CACHE_KEY_UNTIL, 0);
      if (!$allowStale && $until > 0 && $until < time()) {
         return [];
      }
      $data = Utils::jsonDecode($json);
      if (!is_array($data)) {
         return [];
      }
      // Pode vir como {packages:[...]} ou lista diretamente
      if (isset($data['packages']) && is_array($data['packages'])) {
         return $data['packages'];
      }
      return $data;
   }

   /**
    * Atualiza cache via Portal (HTTP). Deve ser chamado apenas por:
    * - CronTask (1x/dia 01:00-04:00)
    * - Abertura do catálogo (throttle)
    */
   public static function refreshPackagesCache(bool $force = false): bool {
      // No uso normal da aplicação, nunca bate no portal.
      if (!$force) {
         return !empty(self::getPackagesCache(true));
      }

      $now = time();
      $lastTry = (int)Config::get(self::PACKAGES_CACHE_KEY_LAST_TRY_AT, 0);

      // throttle agressivo (evita loop de chamadas quando a página recarrega)
      if ( $lastTry > 0 && ($now - $lastTry) < 60) {
         return false;
      }
      Config::set([self::PACKAGES_CACHE_KEY_LAST_TRY_AT => $now]);

            // Segurança: sem license_key/api_base, não mantém cache antigo.
      if (!\GlpiPlugin\Wbstore\License::isConfigured()) {
         self::clearPackagesCache('license_not_configured');
         return false;
      }

try {
         $client = new StoreClient();
         $list = $client->listPackages();
         if (!is_array($list) || empty($list)) {
            $code = (int)$client->getLastCode();
            $err  = (string)$client->getLastError();

            // 4xx (exceto 429) = licença/credencial inválida => revoga cache imediatamente
            if (self::isDenyError($code)) {
               self::clearPackagesCache('deny code=' . $code . ' err=' . $err . ' url=' . $client->getLastUrl());
               return false;
            }

            try {
               if (class_exists('Toolbox') && method_exists('Toolbox', 'logInFile')) {
                  \Toolbox::logInFile('wbstore_super', '[wbstore] listPackages vazio | code=' . $code . ' err=' . $err . ' url=' . $client->getLastUrl() . "\n");
               }
            } catch (\Throwable $e) {}
            return false;
         }

         // ===== MIN cache (gate): map slug => {can_download,...}
         $items = [];
         if (isset($list['packages']) && is_array($list['packages'])) {
            $items = $list['packages'];
         } else {
            $items = $list;
         }
         $min = [];
         foreach (($items ?: []) as $p) {
            if (!is_array($p)) continue;
            $slug = trim((string)($p['slug'] ?? ($p['plugin_slug'] ?? '')));
            if ($slug === '') continue;
            $can = self::extractCanDownload($p);
            $min[$slug] = [
               'slug'          => $slug,
               'name'          => (string)($p['name'] ?? ''),
               'plugin_dir'    => (string)($p['plugin_dir'] ?? ($p['dir'] ?? $slug)),
               'latest_version'=> (string)($p['latest_version'] ?? ($p['version'] ?? '')),
               'min_glpi'      => (string)($p['min_glpi'] ?? ''),
               'sha256'        => (string)($p['sha256'] ?? ''),
               'download_url'  => (string)($p['download_url'] ?? ''),
            ];
            if ($can !== null) {
               $min[$slug]['can_download'] = $can;
            }
         }

         if (empty($min)) {
            self::clearPackagesCache('empty_min_packages');
            return false;
         }

         $minJson = json_encode($min, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
         if (!is_string($minJson) || $minJson === '') {
            return false;
         }

         // ===== FULL cache (catálogo): salva em arquivo (evita estourar tamanho no DB)
         $fullJson = json_encode($list, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
         if (is_string($fullJson) && $fullJson !== '') {
            $file = self::cacheFile('packages_full_cache.json');
            @file_put_contents($file, $fullJson);
            Config::set([
               self::PACKAGES_FULL_CACHE_KEY_HMAC  => Utils::hmac($fullJson),
               self::PACKAGES_FULL_CACHE_KEY_UNTIL => self::nextNightlyDeadline($now),
            ]);
         }

         // Persist MIN cache in DB até a próxima janela noturna
         $ttlUntil = self::nextNightlyDeadline($now);
         Config::set([
            self::PACKAGES_CACHE_KEY_JSON       => $minJson,
            self::PACKAGES_CACHE_KEY_HMAC       => Utils::hmac($minJson),
            self::PACKAGES_CACHE_KEY_UNTIL      => $ttlUntil,
            self::PACKAGES_CACHE_KEY_LAST_OK_AT => $now,
         ]);
         return true;
      } catch (\Throwable $e) {
         return false;
      }
   }

   /**
    * Deve ser chamado ao abrir o catálogo.
    * Atualiza licença + cache (sem travar o GLPI).
    */
   public static function onCatalogOpen(): void {
      // Catálogo não dispara validação remota automática.
      // O refresh online fica restrito ao cron noturno ou ao clique manual em sincronizar.
      Utils::ensurePortalToken();
   }

   private static function nextNightlyDeadline(int $now): int {
      $tzName = @date_default_timezone_get();
      if (!is_string($tzName) || $tzName === '') {
         $tzName = 'UTC';
      }
      $tz = new \DateTimeZone($tzName);
      $dt = (new \DateTimeImmutable('@' . $now))->setTimezone($tz);
      return $dt->modify('tomorrow')->setTime(5, 0, 0)->getTimestamp();
   }

/** @var array<string,array> */
   private static array $lastPluginError = [];

   /**
    * Guarda o último erro/decisão por plugin.
    * Plugins filhos usam isso para diagnóstico (sem chute).
    */
   private static function setLastPluginError(string $slug, array $info): void {
      $slug = $slug !== '' ? $slug : 'unknown';
      self::$lastPluginError[$slug] = $info;

      // Só registra eventos relevantes. "allowed (cached)" repetido só pesa I/O e não ajuda no diagnóstico.
      $shouldLog = !((bool)($info['ok'] ?? false));
      if (!$shouldLog) {
         $err  = (string)($info['err'] ?? '');
         $note = (string)($info['note'] ?? '');
         $shouldLog = ($err !== '');
         if (!$shouldLog) {
            foreach (['blocked', 'denied', 'expired', 'missing', 'error', 'exception'] as $needle) {
               if (stripos($note, $needle) !== false) {
                  $shouldLog = true;
                  break;
               }
            }
         }
      }

      if ($shouldLog) {
         try {
            $line = '[guard] ' . $slug . ' | ' . json_encode($info, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if (class_exists('Toolbox') && method_exists('Toolbox', 'logInFile')) {
               \Toolbox::logInFile('wbstore_guard', $line . "\n");
            } else {
               error_log($line);
            }
         } catch (\Throwable $e) {
            // ignore
         }
      }
   }

   /**
    * Retorna o último erro/decisão para um plugin.
    */
   public static function getLastPluginError(string $slug): array {
      return self::$lastPluginError[$slug] ?? [];
   }

   /**
    * Checagem de estado do WBStore via DB (evita Plugin::isActivated estático que quebra em alguns GLPI/PHP).
    */
   private static function isWbstoreActiveByDB(): bool {
      global $DB;
      try {
         if (!isset($DB)) {
            return false;
         }
         $res = $DB->request([
            'FROM'  => 'glpi_plugins',
            'WHERE' => ['directory' => 'wbstore'],
            'LIMIT' => 1
         ]);
         foreach ($res as $row) {
            return ((int)($row['state'] ?? 0) === 1);
         }
      } catch (\Throwable $e) {
         return false;
      }
      return false;
   }

   /**
    * API principal para plugins filhos: valida WBStore ativo + licença válida + pacote liberado.
    * IMPORTANTE: sem licença, SEMPRE retorna false.
    */
   public static function isPluginAllowedMonthly(string $slug, bool $forceRemote = false): bool {
      // nunca bloquear o próprio WBStore
      if ($slug === 'wbstore') {
         return true;
      }

      // 1) WBStore ativo?
      if (!self::isWbstoreActiveByDB()) {
         self::setLastPluginError($slug, [
            'ok'   => false,
            'err'  => 'wbstore_inactive_or_updating',
            'note' => 'WBStore não está ativo (state!=1).'
         ]);
         return false;
      }

      // 2) Licença configurada?
      if (!License::isConfigured()) {
         self::setLastPluginError($slug, [
            'ok'   => false,
            'err'  => 'license_not_configured',
            'note' => 'WBStore sem integração configurada.'
         ]);
         return false;
      }

      // 3) Licença válida? (pode forçar remoto)
      if (!License::isValid($forceRemote)) {
         self::setLastPluginError($slug, [
            'ok'   => false,
            'err'  => 'license_invalid_or_revoked',
            'note' => 'Licença inválida/revogada ou validação remota falhou sem grace.'
         ]);
         return false;
      }

      // 4) Pacote liberado? (SÓ cache local). HTTP externo NUNCA roda no carregamento.
      // Regra: Plugins devem consultar apenas o cache assinado. Se cache expirou/ausente, negar e mandar abrir o catálogo.
      $lastOk = (int)Config::get(self::PACKAGES_CACHE_KEY_LAST_OK_AT, 0);
      $graceDays = (int)Config::get('offline_grace_days', 7);
      $graceDays = max(0, min(30, $graceDays));

      $cache = self::getPackagesCache(true);

      // Sem cache válido: só tenta remoto se forceRemote=true (cron/catálogo)
      if (empty($cache)) {
         if ($forceRemote) {
            self::refreshPackagesCache(true);
            $cache = self::getPackagesCache(true);
         }
      }

      if (empty($cache)) {
         self::setLastPluginError($slug, [
            'ok'   => false,
            'err'  => 'packages_cache_empty',
            'note' => 'Cache de pacotes ausente/ inválido. Abra o catálogo da WBStore para sincronizar.'
         ]);
         return false;
      }

      // Anti-forja: exige que tenha havido validação online recente (grace)
      if ($graceDays === 0 && $lastOk <= 0) {
         self::setLastPluginError($slug, [
            'ok'=>false,'err'=>'online_check_required','note'=>'Nenhuma validação online registrada. Abra o catálogo da WBStore.'
         ]);
         return false;
      }
      if ($graceDays > 0 && $lastOk > 0) {
         if ((time() - $lastOk) > ($graceDays * 86400)) {
            self::setLastPluginError($slug, [
               'ok'=>false,'err'=>'online_check_expired','note'=>'Cache muito antigo. Abra o catálogo da WBStore para renovar a validação online.'
            ]);
            return false;
         }
      }

      $found = self::findPackage($slug, $cache);
      if ($found === null) {
         self::setLastPluginError($slug, [
            'ok'   => false,
            'err'  => 'package_not_found',
            'note' => 'Pacote não encontrado no cache. Sincronize pelo catálogo.'
         ]);
         return false;
      }

      $can = self::extractCanDownload($found);
      if ($can === false) {
         self::setLastPluginError($slug, [
            'ok'   => false,
            'err'  => 'package_not_allowed',
            'note' => 'Pacote bloqueado para esta licença (cache).'
         ]);
         return false;
      }

      self::setLastPluginError($slug, [
         'ok'   => true,
         'err'  => '',
         'note' => 'allowed (cached)'
      ]);
      return true;

   }

/** Compat aliases */
   public static function isAllowedMonthly(string $slug, bool $forceRemote = false): bool {
      return self::isPluginAllowedMonthly($slug, $forceRemote);
   }
   public static function isPluginAllowed(string $slug): bool {
      return self::isPluginAllowedMonthly($slug, false);
   }

   private static function extractCanDownload(array $p): ?bool {
      foreach (['can_download','allowed','canInstall','enabled','active'] as $k) {
         if (array_key_exists($k, $p)) {
            return (bool)$p[$k];
         }
      }
      // unknown
      return null;
   }

   private static function findPackage(string $slug, array $data): ?array {
      // data can be map {slug: {...}} or list [...]
      if (isset($data[$slug]) && is_array($data[$slug])) {
         return $data[$slug];
      }
      foreach ($data as $k => $v) {
         if (!is_array($v)) {
            continue;
         }
         $cands = [
            (string)($v['slug'] ?? ''),
            (string)($v['plugin_slug'] ?? ''),
            (string)($v['directory'] ?? ''),
            (string)($v['plugin_dir'] ?? ''),
            (string)($v['name'] ?? ''),
            (string)($k),
         ];
         foreach ($cands as $c) {
            if ($c !== '' && $c === $slug) {
               return $v;
            }
         }
      }
      return null;
   }

   public static function isAllowed(): bool {
      // Compat: WBStore ativo + licença válida (não usa isPluginAllowedMonthly('wbstore'), pois esse retorna true por design)
      if (!self::isWbstoreActiveByDB()) {
         return false;
      }
      return License::isValid(false);
   }

   public static function requireAllowed(string $pluginSlug = ''): void {
      // WBStore itself must NEVER be blocked, otherwise the admin can't open
      // configuration to fix license/instance settings.
      if ($pluginSlug === 'wbstore') {
         return;
      }
      if (!self::isAllowed()) {
         // Deny with clear message
         if (class_exists('Html')) {
            \Html::header('Acesso restrito', $_SERVER['PHP_SELF'], 'config', 'plugins');
            echo "<div class='center' style='max-width:980px;margin:0 auto'>";
            echo "<div class='alert alert-danger' style='margin-top:20px'>";
            echo "<b>Bloqueado:</b> este plugin só funciona com a <b>WBStore</b> ativa e com <b>licença válida</b>.";
            if ($pluginSlug !== '') {
               echo "<div style='margin-top:8px;opacity:.9'>Plugin: <code>" . htmlspecialchars($pluginSlug, ENT_QUOTES, 'UTF-8') . "</code></div>";
            }
            echo "<div style='margin-top:10px'>Abra a configuração da WBStore e valide o acesso.</div>";
            echo "</div>";
            echo "<a class='btn btn-primary' href='" . \Plugin::getWebDir('wbstore') . "/front/config.php'><i class='fas fa-store'></i> Abrir WBStore</a>";
            echo "</div>";
            \Html::footer();
            exit;
         }
         // fallback
         die('Blocked: WBStore license required.');
      }
   }

   /**
    * Sends a one-time "plugin.activated" event per plugin+version.
    */
   public static function activationOnce(string $slug, string $version): void {
      if (!self::isAllowed()) {
         return;
      }
      $sent = (string)Config::get('activated_sent', '');
      $list = [];
      if ($sent !== '') {
         $list = Utils::jsonDecode($sent);
         if (!is_array($list)) {
            $list = [];
         }
      }
      $key = $slug . '@' . $version;
      if (isset($list[$key]) && $list[$key]) {
         return;
      }
      Webhook::event('plugin.activated', [
         'plugin_slug' => $slug,
         'plugin_version' => $version,
      ]);
      $list[$key] = 1;
      Config::set(['activated_sent' => json_encode($list)]);
   }
}
