<?php

namespace KeyLair;

/**
 * Backs the admin "Design" screen: every themed page's CSS can be
 * downloaded, replaced with a designer's own file, previewed, and
 * published — without ever losing the originally shipped version and
 * without affecting real visitors until the admin explicitly publishes.
 *
 * - The shipped file in resources/css-defaults/{key}.css is the permanent
 *   template. It is never written to by the application.
 * - An uploaded file first becomes a *draft* (page_styles.draft_css_content).
 *   Only an authenticated admin can see it, via the preview pages.
 * - "Publish" copies the draft into the *active* column, which is what
 *   every visitor's browser actually loads.
 */
class PageStyle
{
    /**
     * style_key => human label. Every key here is still a real, servable
     * stylesheet (public/css.php routes through isValidKey() for all five)
     * -- but only CUSTOMIZABLE_PAGES below can actually be uploaded to from
     * admin/design.php. The two admin-panel keys stay listed here because
     * they still need to be served (always via their shipped default now,
     * since the backend theme can never be 'custom' -- see
     * Settings::BACKEND_THEMES), just not customized.
     */
    public const PAGES = [
        'tokens' => 'Design tokens (colors, fonts) — shared by every page',
        'claim-overview' => 'Overview page (list of products for a claim link)',
        'product-page' => 'Product claim page (platform selection, key reveal)',
        'admin-dashboard' => 'Admin panel (sidebar, dashboard, tables)',
        'admin-product-editor' => 'Admin product editor form',
    ];

    /**
     * Subset of PAGES that admin/design.php actually offers upload/draft/
     * publish controls for -- the customer-facing frontend only.
     * Deliberately excludes 'tokens' too: it's no longer independently
     * customizable (see css.php's resolution logic), so a designer wanting
     * different colors/fonts under Custom theme puts a :root{...} block
     * directly in their claim-overview.css or product-page.css upload --
     * it loads after the shared tokens.css and wins the cascade for
     * whichever variables it redefines, no separate tokens upload needed.
     */
    public const CUSTOMIZABLE_PAGES = [
        'claim-overview' => 'Overview page (list of products for a claim link)',
        'product-page' => 'Product claim page (platform selection, key reveal)',
    ];

    private const MAX_UPLOAD_BYTES = 300 * 1024; // 300 KB is generous for a stylesheet

    public static function isValidKey(string $key): bool
    {
        return isset(self::PAGES[$key]);
    }

    private static function defaultPath(string $key): string
    {
        return ROOT_PATH . '/resources/css-defaults/' . $key . '.css';
    }

    public static function defaultContent(string $key): string
    {
        $path = self::defaultPath($key);
        return file_exists($path) ? file_get_contents($path) : '/* default not found */';
    }

    /**
     * The 'tokens' content for a built-in (non-custom) theme -- 'default'
     * reuses the plain shipped tokens.css; 'light' and 'steam' have their
     * own file (resources/css-defaults/tokens-{theme}.css), each defining
     * the exact same custom-property names with different values, since
     * every other stylesheet only ever references var(--x).
     */
    public static function builtInThemeTokens(string $theme): string
    {
        // Defense in depth: callers should only ever pass an already-validated
        // Settings::THEMES value, but this builds a filesystem path from it, so
        // never trust that alone -- fall back to the safe default for anything
        // unexpected instead of reading an arbitrary path.
        if (!in_array($theme, ['light', 'steam'], true)) {
            return self::defaultContent('tokens');
        }
        $path = ROOT_PATH . '/resources/css-defaults/tokens-' . $theme . '.css';
        return file_exists($path) ? file_get_contents($path) : self::defaultContent('tokens');
    }

    private static function row(string $key): ?array
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare('SELECT * FROM page_styles WHERE style_key = :key');
        $stmt->execute(['key' => $key]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    /** What every visitor's browser loads. Falls back to the shipped default if never published. */
    public static function activeContent(string $key): string
    {
        $row = self::row($key);
        return $row ? $row['css_content'] : self::defaultContent($key);
    }

    /** What the preview shows: the pending draft if there is one, otherwise whatever is currently active. */
    public static function previewContent(string $key): string
    {
        $row = self::row($key);
        if ($row && $row['draft_css_content'] !== null) {
            return $row['draft_css_content'];
        }
        return self::activeContent($key);
    }

    public static function status(string $key): array
    {
        $row = self::row($key);
        return [
            'is_published_custom' => $row !== null,
            'published_at' => $row['updated_at'] ?? null,
            'has_draft' => $row !== null && $row['draft_css_content'] !== null,
            'draft_at' => $row['draft_updated_at'] ?? null,
        ];
    }

    /**
     * Save an uploaded file as a draft — does NOT go live until publish() is called.
     * @return array{ok: bool, error?: string}
     */
    public static function saveDraft(string $key, string $content): array
    {
        $validation = self::validateContent($content);
        if (!$validation['ok']) {
            return $validation;
        }

        $pdo = Database::connection();
        // Ensure a row exists so the draft has somewhere to live even before first publish.
        $stmt = $pdo->prepare(
            'INSERT INTO page_styles (style_key, css_content, draft_css_content, draft_updated_at)
             VALUES (:key, :fallback, :draft, NOW())
             ON DUPLICATE KEY UPDATE draft_css_content = :draft2, draft_updated_at = NOW()'
        );
        $stmt->execute([
            'key' => $key,
            // If this is the very first customization ever, seed the active column with the
            // shipped default so "active" is well-defined until the draft is published.
            'fallback' => self::defaultContent($key),
            'draft' => $content,
            'draft2' => $content,
        ]);

        return ['ok' => true];
    }

    /** Promote the current draft to live for all visitors. */
    public static function publish(string $key): bool
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare(
            'UPDATE page_styles
             SET css_content = draft_css_content, updated_at = NOW(),
                 draft_css_content = NULL, draft_updated_at = NULL
             WHERE style_key = :key AND draft_css_content IS NOT NULL'
        );
        $stmt->execute(['key' => $key]);
        return $stmt->rowCount() > 0;
    }

    public static function discardDraft(string $key): void
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare(
            'UPDATE page_styles SET draft_css_content = NULL, draft_updated_at = NULL WHERE style_key = :key'
        );
        $stmt->execute(['key' => $key]);
    }

    public static function resetToDefault(string $key): void
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare('DELETE FROM page_styles WHERE style_key = :key');
        $stmt->execute(['key' => $key]);
    }

    /**
     * Extra check for CSS that will be embedded inline inside a <style> tag
     * (per-product custom CSS on the claim page, see Product::customCss())
     * rather than served as its own text/css response -- on top of the
     * usual checks, this also blocks anything that could break out of the
     * <style> tag into the surrounding HTML.
     */
    public static function validateInlineCss(string $content): array
    {
        $base = self::validateContent($content);
        if (!$base['ok']) {
            return $base;
        }
        if (preg_match('/<\/\s*style/i', $content)) {
            return ['ok' => false, 'error' => 'CSS contains disallowed content.'];
        }
        return ['ok' => true];
    }

    private static function validateContent(string $content): array
    {
        if ($content === '') {
            return ['ok' => false, 'error' => 'The uploaded file is empty.'];
        }
        if (strlen($content) > self::MAX_UPLOAD_BYTES) {
            return ['ok' => false, 'error' => 'File is too large (max 300 KB).'];
        }
        // Defense in depth: this is always served with a CSS content-type and never
        // executed, but reject anything that looks like an attempt to smuggle in a script.
        if (preg_match('/<\?php|<script/i', $content)) {
            return ['ok' => false, 'error' => 'File contains disallowed content.'];
        }
        return ['ok' => true];
    }
}
