<?php

declare(strict_types=1);

namespace PpPhp\Core\Projects;

use PpPhp\Core\DatabaseConnection;

/**
 * Curated, read-only project templates that ship INSIDE the codebase
 * (core/Projects/builtin-templates/) rather than in per-owner storage. Because
 * they live in the shipped source tree — not the excluded storage/ dir or the
 * console DB — they survive a fresh install / server migration and are always
 * available to the Owner. Each entry reuses the standard project-package format
 * (design + custom files + schema blueprint); a "populated" entry may also carry
 * a PII-free content seed (sanitized SQL) loaded after import.
 *
 * These are never editable or deletable through the console — they are platform
 * assets, not owner data.
 */
final class BuiltinTemplates
{
    /** All role tiers, most-privileged first. Owner can never be locked out. */
    public const ROLES = ['owner', 'superadmin', 'developer', 'user'];
    /** PlatformSettings key holding the owner's per-template audience overrides. */
    private const ACCESS_KEY = 'builtin_template_access';
    /** PlatformSettings key holding per-template individual-user grants ({id:[userId]}). */
    private const USERS_KEY = 'builtin_template_users';

    private static function baseDir(): string
    {
        return rtrim((string) (defined('BASE_PATH') ? constant('BASE_PATH') : getcwd()), '/');
    }

    /** The codebase source of templates — present on an authority, stripped from client bundles. */
    private static function sourceDir(): string
    {
        return self::baseDir() . '/core/Projects/builtin-templates';
    }

    /** The client's remotely-fetched cache (TemplateClient writes here). */
    private static function cacheDir(): string
    {
        return self::baseDir() . '/storage/template-cache';
    }

    /**
     * The active templates directory: the codebase source on an authority (holds
     * the packages), else the client's fetched cache. A client bundle ships no
     * source dir, so it transparently reads the licence-verified cache instead.
     */
    private static function dir(): string
    {
        $src = self::sourceDir();
        return (is_dir($src) && is_file($src . '/index.json')) ? $src : self::cacheDir();
    }

    /** True on a client install — templates come from the remote cache, not the codebase. */
    public static function usesRemoteCache(): bool
    {
        return self::dir() === self::cacheDir();
    }

    /** Owner-set audience overrides ({id: [roles]}), or [] when unset/unavailable. */
    private static function accessOverrides(): array
    {
        if (!class_exists('\PpPhp\Core\PlatformSettings')) {
            return [];
        }
        try {
            return (array) \PpPhp\Core\PlatformSettings::get(self::ACCESS_KEY, []);
        } catch (\Throwable) {
            return [];
        }
    }

    /**
     * The role tiers allowed to see/use a built-in template: the owner's override
     * if set, else the template's index.json default. Owner is ALWAYS included so
     * the owner can never lock themselves out of a platform asset.
     */
    public static function rolesFor(string $id, array $default): array
    {
        $ov = self::accessOverrides();
        $roles = (isset($ov[$id]) && is_array($ov[$id]))
            ? array_values(array_intersect(self::ROLES, $ov[$id]))
            : array_values(array_intersect(self::ROLES, $default));
        if (!in_array('owner', $roles, true)) {
            array_unshift($roles, 'owner');
        }
        return array_values(array_unique($roles));
    }

    /** Persist an owner-set audience for a built-in (owner is force-included). */
    public static function setRoles(string $id, array $roles): bool
    {
        if (self::find($id) === null || !class_exists('\PpPhp\Core\PlatformSettings')) {
            return false;
        }
        $roles = array_values(array_intersect(self::ROLES, $roles));
        if (!in_array('owner', $roles, true)) {
            array_unshift($roles, 'owner');
        }
        $ov = self::accessOverrides();
        $ov[$id] = array_values(array_unique($roles));
        return \PpPhp\Core\PlatformSettings::set(self::ACCESS_KEY, $ov);
    }

    /** Per-template individual-user grants ({id:[userId]}), or [] when unset/unavailable. */
    private static function userOverrides(): array
    {
        if (!class_exists('\PpPhp\Core\PlatformSettings')) return [];
        try { return (array) \PpPhp\Core\PlatformSettings::get(self::USERS_KEY, []); }
        catch (\Throwable) { return []; }
    }

    /** The individual user IDs explicitly granted this template (regardless of group). @return array<int,int> */
    public static function usersFor(string $id): array
    {
        $ov = self::userOverrides();
        $ids = is_array($ov[$id] ?? null) ? $ov[$id] : [];
        $out = [];
        foreach ($ids as $u) { $u = (int) $u; if ($u > 0 && !in_array($u, $out, true)) $out[] = $u; }
        return $out;
    }

    /** Persist the individual users granted a built-in. @param array<int,int|string> $userIds */
    public static function setUsers(string $id, array $userIds): bool
    {
        if (self::find($id) === null || !class_exists('\PpPhp\Core\PlatformSettings')) return false;
        $clean = [];
        foreach ($userIds as $u) { $u = (int) $u; if ($u > 0 && !in_array($u, $clean, true)) $clean[] = $u; }
        $ov = self::userOverrides();
        if ($clean === []) { unset($ov[$id]); }
        else { $ov[$id] = $clean; }
        return \PpPhp\Core\PlatformSettings::set(self::USERS_KEY, $ov);
    }

    /**
     * Central visibility rule: may this viewer see/use this template? True when the
     * viewer's user id is EXPLICITLY granted (bypasses the group gate), or their role
     * tier is in the template's audience (the `user` tier still requires templates to
     * be opened to users). Owner is always in the audience.
     *
     * @param array<string,mixed> $template  a row from all()/find()
     */
    public static function canUse(array $template, string $tier, int $userId, bool $openToUsers): bool
    {
        $users = array_map('intval', (array) ($template['users'] ?? []));
        if ($userId > 0 && in_array($userId, $users, true)) {
            return true;   // explicit individual grant wins over any group gate
        }
        $roles = (array) ($template['roles'] ?? []);
        if (!in_array($tier, $roles, true)) return false;
        if ($tier === 'user' && !$openToUsers) return false;   // user group gated by the master toggle
        return true;
    }

    /** A friendly one-line audience label for the given roles. */
    public static function audienceLabel(array $roles): string
    {
        $set = array_values(array_intersect(self::ROLES, $roles));
        if (count($set) >= 4) {
            return 'Everyone';
        }
        if ($set === ['owner']) {
            return 'Owner only';
        }
        $names = ['owner' => 'Owner', 'superadmin' => 'Superadmin', 'developer' => 'Developer', 'user' => 'User'];
        return implode(' · ', array_map(static fn(string $r): string => $names[$r] ?? $r, $set));
    }

    /** All available built-in templates, shaped like catalog rows (+ builtin flag). */
    public static function all(): array
    {
        $index = self::dir() . '/index.json';
        if (!is_file($index)) {
            return [];
        }
        $data = json_decode((string) file_get_contents($index), true);
        if (!is_array($data)) {
            return [];
        }
        $out = [];
        foreach ((array) ($data['templates'] ?? []) as $t) {
            if (!is_array($t) || empty($t['id']) || empty($t['package'])) {
                continue;
            }
            $package = self::dir() . '/' . basename((string) $t['package']);
            $available = is_file($package);
            // Authority: a missing package is a broken asset — hide it. Client: the
            // package is fetched on demand, so list it now (available=false) and let
            // "Use" download it.
            if (!$available && !self::usesRemoteCache()) {
                continue;
            }
            $seed = !empty($t['seed']) ? self::dir() . '/' . basename((string) $t['seed']) : null;
            // Default audience: index.json `roles`, else owner-only for content
            // templates (they carry data) and everyone for content-free starters.
            $defaultRoles = isset($t['roles']) && is_array($t['roles'])
                ? array_values(array_intersect(self::ROLES, $t['roles']))
                : (!empty($t['has_content']) ? ['owner'] : self::ROLES);
            $roles = self::rolesFor((string) $t['id'], $defaultRoles);
            $users = self::usersFor((string) $t['id']);
            $out[] = [
                'id'           => (string) $t['id'],
                'builtin'      => true,
                'roles'        => $roles,
                'users'        => $users,
                'audience'     => self::audienceLabel($roles) . ($users !== [] ? ' + ' . count($users) . ' user' . (count($users) === 1 ? '' : 's') : ''),
                'name'         => (string) ($t['name'] ?? $t['id']),
                'slug'         => (string) ($t['slug'] ?? $t['id']),
                'description'  => (string) ($t['description'] ?? ''),
                'category'     => (string) ($t['category'] ?? 'Business'),
                'tags'         => array_values((array) ($t['tags'] ?? [])),
                'icon'         => (string) ($t['icon'] ?? 'stack'),
                'color'        => (string) ($t['color'] ?? '#4f46e5'),
                'package_path' => $package,
                'seed_path'    => $seed,   // expected path; on a client it exists after fetch()
                'has_content'  => (bool) ($t['has_content'] ?? false),
                // Template-specific "already populated" probe table for loadSeed's
                // clobber-guard (defaults to the forum table for back-compat).
                'content_check_table' => (string) ($t['content_check_table'] ?? 'qa_questions'),
                'version'      => (string) ($t['version'] ?? '1.0'),
                'available'    => $available,
                'size'         => $available ? (int) filesize($package) : 0,
                // executable_custom_files must be > 0 so the "use" flow shows the
                // active-code confirmation the importer requires for these packages.
                'content'      => ['executable_custom_files' => max(1, (int) ($t['code_files'] ?? 1))],
                'database'     => ['type' => 'mysql'],
                'manifest'     => [],
                'updated_at'   => date('Y-m-d H:i:s', $available ? (int) filemtime($package) : time()),
            ];
        }
        return $out;
    }

    /** A single built-in by id, or null. */
    public static function find(string $id): ?array
    {
        foreach (self::all() as $t) {
            if ($t['id'] === $id) {
                return $t;
            }
        }
        return null;
    }

    /** True when an id refers to a built-in (they use non-numeric ids; catalog ids are integers). */
    public static function isBuiltinId(string $id): bool
    {
        return !ctype_digit($id) && self::find($id) !== null;
    }

    /**
     * Load a built-in's PII-free content seed into a freshly-created project's
     * database. MySQL/MariaDB only (the seed is MySQL dialect). Skips when the
     * target already has content, so it can never clobber or duplicate existing
     * data. Returns a status array — a data-load hiccup never aborts the project.
     */
    public static function loadSeed(array $connection, string $projectDir, string $seedPath, string $checkTable = 'qa_questions'): array
    {
        if (!is_file($seedPath)) {
            return ['loaded' => 0, 'skipped' => true, 'message' => 'No content seed for this template.'];
        }
        $conn = DatabaseConnection::normalize($connection);
        if (!in_array((string) $conn['type'], ['mysql', 'mariadb'], true)) {
            return ['loaded' => 0, 'skipped' => true, 'message' => 'Content restore supports MySQL/MariaDB targets; the project was created empty.'];
        }
        try {
            $driver = DatabaseConnection::preferredDriver($conn['type']);
            $dsn = DatabaseConnection::buildDsn($conn, $projectDir, $driver);
            $pdo = new \PDO($dsn, (string) ($conn['user'] ?? ''), (string) ($conn['pass'] ?? ''), [
                \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
            ]);
        } catch (\Throwable $e) {
            return ['loaded' => 0, 'skipped' => true, 'message' => 'Could not connect to the target database to load content.'];
        }

        // Never overwrite an already-populated target. The "populated" probe table is
        // template-specific (a forum uses qa_questions; the legal CMS uses cases, etc.)
        // so a seed can never clobber a project that already holds real data.
        $probe = preg_replace('/[^A-Za-z0-9_]/', '', $checkTable) ?: 'qa_questions';
        try {
            $exists = $pdo->query("SHOW TABLES LIKE " . $pdo->quote($probe))->fetchColumn();
            if ($exists) {
                $count = (int) $pdo->query('SELECT COUNT(*) FROM `' . $probe . '`')->fetchColumn();
                if ($count > 0) {
                    return ['loaded' => 0, 'skipped' => true, 'message' => 'The target database already has content — the content seed was skipped.'];
                }
            }
        } catch (\Throwable) {
            // table missing is fine — we are about to create it
        }

        $statements = array_filter(array_map('trim', preg_split('/\n--\s*@@\s*\n/', (string) file_get_contents($seedPath)) ?: []));
        $loaded = 0;
        try {
            $pdo->exec('SET FOREIGN_KEY_CHECKS=0');
            foreach ($statements as $stmt) {
                // strip pure-comment lines; skip empty chunks
                $clean = trim(preg_replace('/^\s*--.*$/m', '', $stmt) ?? '');
                if ($clean === '') {
                    continue;
                }
                $pdo->exec($clean);
                $loaded++;
            }
            $pdo->exec('SET FOREIGN_KEY_CHECKS=1');
        } catch (\Throwable $e) {
            try { $pdo->exec('SET FOREIGN_KEY_CHECKS=1'); } catch (\Throwable) {}
            return ['loaded' => $loaded, 'skipped' => false, 'message' => 'Content load stopped early: ' . $e->getMessage()];
        }
        return ['loaded' => $loaded, 'skipped' => false, 'message' => 'Seeded template content (' . $loaded . ' statements).'];
    }
}
