<?php
// components/ModuleLoader.php
// Provides a single source of truth for module definitions used across the UI

namespace Components;

class ModuleLoader
{
    /**
     * Per-request memo of synced areas to avoid repeated work.
     * @var array<string,bool>
     */
    private static $syncedAreas = [];

    /** How long to consider registry fresh across requests when a session is active (seconds). */
    private const SYNC_TTL = 600; // 10 minutes

    /** Session bucket key for cross-request sync timestamps. */
    private const SESSION_BUCKET = '__mbz_sync_areas_ts';

    /**
     * Normalize input areas to a list.
     * @param string|array<string>|null $areas
     * @return array<int,string>
     */
    private static function normalizeAreas($areas): array
    {
        $allAreas = ['mailbeez','configbeez','filterbeez','reportbeez','dashboardbeez'];
        if ($areas === null) { return $allAreas; }
        if (is_string($areas)) { return [$areas]; }
        return array_values(array_filter(array_map('strval', $areas)));
    }

    /** Return true when a PHP session is active. */
    private static function hasActiveSession(): bool
    {
        return function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE;
    }

    /** Ensure the session bucket exists (no-op if there is no active session). */
    private static function ensureSessionBucket(): void
    {
        if (!self::hasActiveSession()) { return; }
        if (!isset($_SESSION[self::SESSION_BUCKET]) || !is_array($_SESSION[self::SESSION_BUCKET])) {
            $_SESSION[self::SESSION_BUCKET] = [];
        }
    }

    /** Get last-sync timestamp for an area from the session bucket, or null. */
    private static function lastSyncTs(string $area): ?int
    {
        if (!self::hasActiveSession()) { return null; }
        if (!isset($_SESSION[self::SESSION_BUCKET][$area])) { return null; }
        $ts = (int) $_SESSION[self::SESSION_BUCKET][$area];
        return $ts > 0 ? $ts : null;
    }

    /** Persist the last-sync timestamp for an area into the session bucket. */
    private static function stampSynced(string $area, ?int $ts = null): void
    {
        if (!self::hasActiveSession()) { return; }
        self::ensureSessionBucket();
        $_SESSION[self::SESSION_BUCKET][$area] = $ts ?? time();
    }

    /** Remove the session timestamp for an area (if present). */
    private static function clearStamp(string $area): void
    {
        if (!self::hasActiveSession()) { return; }
        if (isset($_SESSION[self::SESSION_BUCKET][$area])) {
            unset($_SESSION[self::SESSION_BUCKET][$area]);
        }
    }

    /**
     * Cheap-but-robust check that both installed and versions keys exist and are non-empty/valid for an area.
     * This avoids skipping sync when the cached registry is missing or contains placeholder/invalid values.
     */
    private static function keysPresent(string $area): bool
    {
        try {
            if (function_exists('mh_installed_keys_for_area') && function_exists('mh_cfg')) {
                list($listKey, $verKey) = mh_installed_keys_for_area($area);
                $listRaw = mh_cfg($listKey);
                $verRaw  = mh_cfg($verKey);

                // Trim and coerce to string for robust emptiness checks
                $listVal = is_string($listRaw) ? trim($listRaw) : trim((string) $listRaw);
                $verVal  = is_string($verRaw)  ? trim($verRaw)  : trim((string) $verRaw);

                // Both must be non-empty strings
                if ($listVal === '' || $verVal === '') { return false; }

                // Installed list must contain at least one non-empty item
                if (function_exists('mh_parse_installed_list')) {
                    $arr = mh_parse_installed_list($listVal);
                    if (!is_array($arr) || count($arr) === 0) { return false; }
                }

                // Versions list should at least contain a pipe separator (module|version|...)
                if (strpos($verVal, '|') === false) { return false; }

                return true;
            }
        } catch (\Throwable $e) {
            // best-effort: treat as missing
        }
        return false;
    }

    /**
     * Determine whether the registry for an area is fresh enough to skip syncing.
     */
    private static function isAreaFresh(string $area, bool $force): bool
    {
        if ($force) { return false; }
        $keysOk = self::keysPresent($area);
        if (!$keysOk) { return false; }
        $ts = self::lastSyncTs($area);
        if ($ts === null) { return false; }
        return (time() - $ts) < self::SYNC_TTL;
    }

    /** Mark the area as synced for the current request and update the session stamp. */
    private static function markAreaSynced(string $area): void
    {
        self::$syncedAreas[$area] = true;
        self::stampSynced($area);
    }

    /**
     * Ensure the legacy installed registries are synchronized for the given areas.
     * Heuristics to minimize load:
     * - Only sync the requested area(s) (no global sync by default).
     * - If both installed and versions keys exist and a recent session timestamp is present, skip syncing.
     * - Allow forcing a resync via query param `mbz_resync=1` (or `resync=1`).
     * - Never throw if the legacy write API is unavailable.
     *
     * @param string|array<string>|null $areas null ? all areas; string ? single area; array ? list of areas
     * @param bool $force when true, bypass freshness checks and resync now
     */
    private static function ensureSynced($areas = null, bool $force = false): void
    {
        // Normalize areas and prepare session bucket
        $areas = self::normalizeAreas($areas);
        self::ensureSessionBucket();

        // Optional force flag via query param
        if (!$force ) {
            $force = (bool) (mh_get('mbz_resync', 0) || mh_get('resync', 0));
        }

        foreach ($areas as $area) {
            if (!$force && isset(self::$syncedAreas[$area])) { continue; }

            // Fast path: if keys exist and are fresh in this session, skip heavy sync
            if (self::isAreaFresh($area, $force)) {
                self::markAreaSynced($area);
                continue;
            }

            mh_sync_installed($area);

            // Mark per-request memo and refresh session timestamp
            self::markAreaSynced($area);
        }
    }

    /**
     * Invalidate cached sync state so the next call will re-evaluate freshness or resync.
     * Intended to be called by module install/update/remove actions.
     *
     * @param string|array<string>|null $areas null ? all areas
     * @param bool $includeSession also clear the session TTL cache
     */
    public static function invalidateCache($areas = null, bool $includeSession = true): void
    {
        $areas = self::normalizeAreas($areas);
        foreach ($areas as $area) {
            if (isset(self::$syncedAreas[$area])) {
                unset(self::$syncedAreas[$area]);
            }
            if ($includeSession) {
                self::clearStamp($area);
            }
        }
    }

    /**
     * Force a registry refresh now (bypassing TTL) for the given areas.
     * Safe to call after installing, updating, or removing a module.
     *
     * @param string|array<string>|null $areas null ? all areas
     */
    public static function forceRefresh($areas = null): void
    {
        self::invalidateCache($areas, true);
        self::ensureSynced($areas, true);
        try {
            \Components\Data\DataRegistry::reset();
        } catch (\Throwable $e) {
            // ignore; keep refresh best-effort in edge runtimes
        }
    }
    /**
     * Return a list of module-specific extensions providing additional features.
     * This is simple static data for now; in a real app this could come from an API or config.
     *
     * @return array<int, array{key:string,title:string,desc:string,installed:bool,url?:string}>
     */
    public static function extensions(string $module): array
    {
        // Delegate to the active data provider (mock/live)
        return \Components\Data\DataRegistry::modules()->extensions($module);
    }
    /**
     * Returns campaign modules grouped by e-commerce touchpoints.
     * Each module uses the common format returned by self::common().
     *
     * @param bool|null $onlyInstalled true: only installed, false: only uninstalled, null: all
     * @return array<string, array<int, array<string, mixed>>> group => [modules]
     */
    public static function campaignsByGroup(?bool $onlyInstalled = true): array
    {
        // Campaigns live under mailbeez
        self::ensureSynced('mailbeez');
        // Delegate to the active data provider (mock/live)
        return \Components\Data\DataRegistry::modules()->triggersByGroup($onlyInstalled);
    }

    /**
     * Returns a single campaign module by key.
     *
     * This avoids full cross-area aggregation for module-scoped requests.
     */
    public static function campaignByKey(string $key, ?bool $onlyInstalled = true): ?array
    {
        $key = trim($key);
        if ($key === '') {
            return null;
        }

        $provider = \Components\Data\DataRegistry::modules();
        $item = $provider->triggerByKey($key, $onlyInstalled);
        if (is_array($item)) {
            return $item;
        }

        // Fallback: only sync legacy registry when targeted lookup misses.
        self::ensureSynced('mailbeez');
        return $provider->triggerByKey($key, $onlyInstalled);
    }

    /**
     * Returns service modules grouped by category, same structure as campaignsByGroup().
     * @return array<string, array<int, array<string, mixed>>>
     */
    public static function serviceModulesByGroup(): array
    {
        // Service modules are provided from the mailbeez area
        self::ensureSynced('mailbeez');
        return \Components\Data\DataRegistry::modules()->serviceModulesByGroup();
    }

    /**
     * Returns filter modules grouped by category, same structure as campaignsByGroup().
     * @return array<string, array<int, array<string, mixed>>>
     */
    public static function filterModulesByGroup(): array
    {
        self::ensureSynced('filterbeez');
        return \Components\Data\DataRegistry::modules()->filterModulesByGroup();
    }

    /**
     * Returns configuration modules as a flat list using the common format.
     *
     * @return array<int, array<string, mixed>>
     */
    public static function configuration(): array
    {
        self::ensureSynced('configbeez');
        // Build a flat list from the provider's grouped configuration modules
        $provider = \Components\Data\DataRegistry::modules();
        $groups = $provider->configModulesByGroup();
        $out = [];
        foreach ($groups as $group => $mods) {
            foreach ($mods as $m) {
                // Ensure minimal required fields exist
                if (!isset($m['key']) || !isset($m['title'])) { continue; }
                $out[] = $m;
            }
        }
        return $out;
    }

    /**
     * Grouped configuration modules for the configuration sidebar.
     * Structure: [ groupLabel => [ module(common), ... ] ]
     *
     * @return array<string, array<int, array<string, mixed>>>
     */
    public static function configurationSections(): array
    {
        self::ensureSynced('configbeez');
        // Delegate to the active data provider (mock/live)
        return \Components\Data\DataRegistry::modules()->configModulesByGroup();
    }

    /**
     * Returns all modules in an associative map keyed by module key.
     *
     * @return array<string, array<string, mixed>> key => module
     */
    public static function all(): array
    {

        // todo: expensive request, all modules are loaded
        // Aggregate across all areas, but ensure each is only synced once and throttled by session TTL
        self::ensureSynced(['mailbeez','configbeez','filterbeez','reportbeez','dashboardbeez']);
        // Delegate to provider which aggregates campaigns, services, filters, and configuration
        return \Components\Data\DataRegistry::modules()->all();
    }

    /**
     * Returns title for a given key or null if unknown.
     */
    public static function title(string $key): ?string
    {
        // Titles can come from any area; ensure common ones are fresh but skip heavy work due to throttling
        self::ensureSynced(['mailbeez','configbeez']);
        return \Components\Data\DataRegistry::modules()->title($key);
    }

    /**
     * Whether a module exists (campaign or configuration).
     */
    public static function exists(string $key): bool
    {
        self::ensureSynced(['mailbeez','configbeez','filterbeez']);
        return \Components\Data\DataRegistry::modules()->exists($key);
    }

    /**
     * Whether a module can be uninstalled (has remove() method).
     */
    public static function canUninstall(string $key): bool
    {
        self::ensureSynced(['mailbeez','configbeez']);
        return \Components\Data\DataRegistry::modules()->canUninstall($key);
    }

    /**
     * Resolve the correct blueprint path for a given module.
     * Falls back to the application default blueprint when a module-specific
     * blueprint is not defined or not accessible.
     */
    public static function blueprintPath(string $module): string
    {
        $default = (\defined('BEEZUI_ROOT') ? BEEZUI_ROOT : \dirname(__DIR__)) . '/blueprint.yaml';
        try {
            $all = self::all();
            if (isset($all[$module])) {
                $bp = $all[$module]['blueprint_path'] ?? null;
                if (\is_string($bp) && $bp !== '' && \file_exists($bp)) {
                    return $bp;
                }
            }
            // Fallback: try to resolve via legacy module class from config directory even if not listed in all()
            $legacy = self::legacyBlueprintPath($module);
            if ($legacy !== null && is_string($legacy) && $legacy !== '' && file_exists($legacy)) {
                return $legacy;
            }
        } catch (\Throwable $e) {
            // ignore and use fallback
        }
        return $default;
    }

    /**
     * Return alias module keys for a given configuration module.
     * @return array<int,string>
     */
    public static function aliasModules(string $module): array
    {
        try {
            $all = self::all();
            $am = $all[$module]['alias_modules'] ?? [];
            if (is_string($am) && $am !== '') {
                $am = array_filter(array_map('trim', explode(',', $am)), fn($s) => $s !== '');
            }
            if (is_array($am)) {
                $out = [];
                foreach ($am as $k) { $k = (string)$k; if ($k !== '') { $out[] = $k; } }
                return $out;
            }
        } catch (\Throwable $e) { /* ignore */ }
        return [];
    }

    /**
     * Return merged blueprint paths for a module including its alias modules (if any).
     * The main module blueprint is first, followed by aliases in declared order.
     * @return array<int,string>
     */
    public static function mergedBlueprintPaths(string $module): array
    {
        $paths = [];
        $main = self::blueprintPath($module);
        if (is_string($main) && $main !== '' && file_exists($main)) { $paths[] = $main; }
        foreach (self::aliasModules($module) as $alias) {
            $bp = self::blueprintPath($alias);
            if (is_string($bp) && $bp !== '' && file_exists($bp)) {
                $paths[] = $bp;
            }
        }
        if (empty($paths)) { $paths[] = (\defined('BEEZUI_ROOT') ? BEEZUI_ROOT : \dirname(__DIR__)) . '/blueprint.yaml'; }
        return $paths;
    }

    /** Try to resolve blueprint path by loading legacy class from MH_DIR_CONFIG. */
    protected static function legacyBlueprintPath(string $module): ?string
    {
        try {
            if (!defined('MH_DIR_CONFIG')) { return null; }
            $baseDir = rtrim((string)MH_DIR_CONFIG, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
            $file = $baseDir . $module . (defined('MH_FILE_EXTENSION') ? MH_FILE_EXTENSION : '.php');
            if (!is_file($file)) { return null; }
            // Include class file
            require_once $file;
            if (!class_exists($module)) { return null; }
            $obj = new $module();
            if (method_exists($obj, 'blueprintPath')) {
                $bp = $obj->blueprintPath();
                return is_string($bp) ? $bp : null;
            }
        } catch (\Throwable $e) { /* ignore */ }
        return null;
    }

    /**
     * Returns modules grouped by their group label. Can include:
     * - type = 'campaign' only
     * - type = 'configuration' only
     * - both (default)
     *
     * @param array<string> $types Allowed types filter
     * @return array<string, array<int, array<string, mixed>>> group => [modules]
     */
    public static function byGroup(array $types = ['campaign', 'service', 'filter', 'configuration']): array
    {
        $out = [];
        $all = \Components\Data\DataRegistry::modules()->all();
        foreach ($all as $m) {
            if (!in_array($m['type'], $types, true)) {
                continue;
            }
            $out[$m['group']][] = $m;
        }
        return $out;
    }

    /**
     * Build a normalized module definition.
     *
     * @return array<string, mixed>
     */
    public static function common(string $key, string $title, string $desc = '', string $group = '', string $type = ''): array
    {
        return [
            'key' => $key,
            'title' => $title,
            'desc' => $desc,
            'group' => $group,
            'type' => $type,
        ];
    }

}
