<?php
defined( 'ABSPATH' ) || exit;

/**
 * Data layer for the Class-Based Styling System — reusable, site-wide CSS
 * classes that can be attached to unlimited widgets across every page.
 *
 * A class definition is structurally a headless node (styles/tabletStyles/
 * mobileStyles + a background/border settings bucket), so it is compiled to
 * CSS by Jalpi_Renderer using the exact same helpers as node styles
 * (styles_to_css / bg_data_to_css / border_data_to_css).
 */
class Jalpi_Class_Manager {

    private static $instance = null;

    /** @var array<int,array>|null In-request cache of all class rows, sort_order asc. */
    private ?array $cache = null;

    /** Object-cache key + group backing the in-request cache above. */
    const CACHE_KEY   = 'jalpi_classes_all';
    const CACHE_GROUP = 'jalpi';

    public static function instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->maybe_create_table();
    }

    private function table(): string {
        global $wpdb;
        return $wpdb->prefix . 'jalpi_classes';
    }

    /**
     * Lazily create the jalpi_classes table for sites where the plugin was
     * already active before this feature shipped (the activation hook only
     * runs on a fresh activate, not on a plugin update).
     */
    private function maybe_create_table(): void {
        if ( '1' === get_option( 'jalpi_classes_table_ready' ) ) {
            return;
        }

        global $wpdb;
        $charset = $wpdb->get_charset_collate();
        $table   = $this->table();

        $sql = "CREATE TABLE IF NOT EXISTS {$table} (
            id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            class_key   VARCHAR(64)     NOT NULL,
            name        VARCHAR(191)    NOT NULL,
            styles      LONGTEXT        NOT NULL,
            favorite    TINYINT(1)      NOT NULL DEFAULT 0,
            sort_order  INT UNSIGNED    NOT NULL DEFAULT 0,
            created_by  BIGINT UNSIGNED NOT NULL DEFAULT 0,
            created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            UNIQUE KEY class_key (class_key),
            KEY sort_order (sort_order)
        ) $charset;";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );

        update_option( 'jalpi_classes_table_ready', '1' );
        if ( false === get_option( 'jalpi_classes_version' ) ) {
            add_option( 'jalpi_classes_version', 1 );
        }
    }

    // -------------------------------------------------------------------------
    // Cache-busting version — folded into the render transient key so editing
    // a class invalidates every page's cached render without a usage index.
    // -------------------------------------------------------------------------

    public function version(): int {
        return (int) get_option( 'jalpi_classes_version', 1 );
    }

    private function bump_version(): void {
        update_option( 'jalpi_classes_version', $this->version() + 1 );
    }

    // -------------------------------------------------------------------------
    // Read
    // -------------------------------------------------------------------------

    /** @return array<int,array> All class definitions, sort_order ascending. */
    public function get_all( bool $force_refresh = false ): array {
        if ( null !== $this->cache && ! $force_refresh ) {
            return $this->cache;
        }

        // Persistent object cache before touching the DB — get_all() runs on
        // every render, so a hit removes one query per request.
        if ( ! $force_refresh ) {
            $cached = wp_cache_get( self::CACHE_KEY, self::CACHE_GROUP );
            if ( is_array( $cached ) ) {
                $this->cache = $cached;
                return $this->cache;
            }
        }

        global $wpdb;
        $table = esc_sql( $this->table() );
        // Custom plugin table, so a direct query is unavoidable; the result is
        // stored in the object cache above/below. The only interpolated value
        // is the table name (esc_sql'd) — identifiers cannot be bound as
        // $wpdb->prepare() placeholders.
        // Only the LAST phpcs:ignore before a line applies, so all three codes
        // must live on one comment.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY sort_order ASC, id ASC", ARRAY_A );

        $this->cache = array_map( [ $this, 'row_to_array' ], $rows ?: [] );
        wp_cache_set( self::CACHE_KEY, $this->cache, self::CACHE_GROUP, HOUR_IN_SECONDS );
        return $this->cache;
    }

    public function get_by_key( string $key ): ?array {
        $key = sanitize_key( $key );
        foreach ( $this->get_all() as $row ) {
            if ( $row['key'] === $key ) {
                return $row;
            }
        }
        return null;
    }

    private function row_to_array( array $row ): array {
        $decoded = json_decode( (string) $row['styles'], true );
        $decoded = is_array( $decoded ) ? $decoded : [];

        return [
            'key'            => $row['class_key'],
            'name'           => $row['name'],
            'styles'         => $decoded['styles']       ?? [],
            'tabletStyles'   => $decoded['tabletStyles'] ?? [],
            'mobileStyles'   => $decoded['mobileStyles'] ?? [],
            'settings'       => $decoded['settings']     ?? [],
            'globalStyleRef' => $decoded['globalStyleRef'] ?? null,
            'favorite'       => (bool) $row['favorite'],
            'sortOrder'      => (int) $row['sort_order'],
            'updatedAt'      => $row['updated_at'],
        ];
    }

    /** Ensure the CSS class name is unique among all other classes. */
    private function unique_name( string $base, string $exclude_key = '' ): string {
        $base = '' !== $base ? $base : 'class';
        $existing = [];
        foreach ( $this->get_all() as $row ) {
            if ( $row['key'] !== $exclude_key ) {
                $existing[] = $row['name'];
            }
        }
        if ( ! in_array( $base, $existing, true ) ) {
            return $base;
        }
        $i = 2;
        while ( in_array( $base . '-' . $i, $existing, true ) ) {
            $i++;
        }
        return $base . '-' . $i;
    }

    // -------------------------------------------------------------------------
    // Write
    // -------------------------------------------------------------------------

    /**
     * @param string $name       Desired CSS class name (already sanitized by the caller).
     * @param array  $definition Sanitized class definition (styles/tabletStyles/mobileStyles/settings/favorite).
     */
    public function create( string $name, array $definition = [] ): ?array {
        global $wpdb;

        $name = $this->unique_name( sanitize_html_class( $name ) ?: 'class' );
        $key  = 'cls_' . bin2hex( random_bytes( 4 ) );

        $payload = [
            'styles'         => $definition['styles']       ?? [],
            'tabletStyles'   => $definition['tabletStyles'] ?? [],
            'mobileStyles'   => $definition['mobileStyles'] ?? [],
            'settings'       => $definition['settings']     ?? [],
            'globalStyleRef' => $definition['globalStyleRef'] ?? null,
        ];

        $max_sort = 0;
        foreach ( $this->get_all() as $row ) {
            $max_sort = max( $max_sort, $row['sortOrder'] );
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->insert(
            $this->table(),
            [
                'class_key'  => $key,
                'name'       => $name,
                'styles'     => wp_json_encode( $payload ),
                'favorite'   => ! empty( $definition['favorite'] ) ? 1 : 0,
                'sort_order' => $max_sort + 1,
                'created_by' => get_current_user_id(),
            ],
            [ '%s', '%s', '%s', '%d', '%d', '%d' ]
        );

        $this->cache = null;
        wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP );
        $this->bump_version();

        $created = $this->get_by_key( $key );
        do_action( 'jalpi_class_saved', $key, $created );

        return $created;
    }

    /** Update styles and/or rename an existing class. */
    public function update( string $key, array $definition ): ?array {
        global $wpdb;

        $key = sanitize_key( $key );
        $row = $this->get_by_key( $key );
        if ( ! $row ) {
            return null;
        }

        $update = [];
        $format = [];

        if ( isset( $definition['name'] ) ) {
            $update['name'] = $this->unique_name( sanitize_html_class( $definition['name'] ) ?: $row['name'], $key );
            $format[]       = '%s';
        }

        if ( isset( $definition['styles'] ) || isset( $definition['tabletStyles'] )
             || isset( $definition['mobileStyles'] ) || isset( $definition['settings'] )
             || array_key_exists( 'globalStyleRef', $definition ) ) {
            $payload = [
                'styles'         => $definition['styles']       ?? $row['styles'],
                'tabletStyles'   => $definition['tabletStyles'] ?? $row['tabletStyles'],
                'mobileStyles'   => $definition['mobileStyles'] ?? $row['mobileStyles'],
                'settings'       => $definition['settings']     ?? $row['settings'],
                'globalStyleRef' => array_key_exists( 'globalStyleRef', $definition ) ? $definition['globalStyleRef'] : $row['globalStyleRef'],
            ];
            $update['styles'] = wp_json_encode( $payload );
            $format[]         = '%s';
        }

        if ( isset( $definition['favorite'] ) ) {
            $update['favorite'] = ! empty( $definition['favorite'] ) ? 1 : 0;
            $format[]           = '%d';
        }

        if ( empty( $update ) ) {
            return $row;
        }

        $update['updated_at'] = current_time( 'mysql' );
        $format[]              = '%s';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->update( $this->table(), $update, [ 'class_key' => $key ], $format, [ '%s' ] );

        $this->cache = null;
        wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP );
        $this->bump_version();

        $updated = $this->get_by_key( $key );
        do_action( 'jalpi_class_saved', $key, $updated );

        return $updated;
    }

    public function duplicate( string $key ): ?array {
        $row = $this->get_by_key( $key );
        if ( ! $row ) {
            return null;
        }
        return $this->create( $row['name'] . '-copy', [
            'styles'         => $row['styles'],
            'tabletStyles'   => $row['tabletStyles'],
            'mobileStyles'   => $row['mobileStyles'],
            'settings'       => $row['settings'],
            'globalStyleRef' => $row['globalStyleRef'],
        ] );
    }

    /**
     * Delete a class. Node references to a deleted key are simply skipped at
     * render time (Jalpi_Renderer::get_css_attrs) — no cross-page cascade
     * scan is run, keeping deletes cheap regardless of site size.
     */
    public function delete( string $key ): bool {
        global $wpdb;
        $key = sanitize_key( $key );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $result = $wpdb->delete( $this->table(), [ 'class_key' => $key ], [ '%s' ] );

        $this->cache = null;
        wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP );
        if ( $result ) {
            $this->bump_version();
            do_action( 'jalpi_class_deleted', $key );
        }
        return (bool) $result;
    }

    public function toggle_favorite( string $key ): ?array {
        $row = $this->get_by_key( $key );
        if ( ! $row ) {
            return null;
        }
        return $this->update( $key, [ 'favorite' => ! $row['favorite'] ] );
    }

    /** Persist manager list order — this order also drives multi-class CSS cascade precedence. */
    public function reorder( array $ordered_keys ): void {
        global $wpdb;
        $table = $this->table();

        $i = 1;
        foreach ( $ordered_keys as $key ) {
            $key = sanitize_key( $key );
            if ( ! $key ) {
                continue;
            }
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->update( $table, [ 'sort_order' => $i ], [ 'class_key' => $key ], [ '%d' ], [ '%s' ] );
            $i++;
        }

        $this->cache = null;
        wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP );
        $this->bump_version();
    }

    /**
     * On-demand global usage count via a LIKE scan of the pages table. Cheap
     * at realistic builder-site scale; only called when a delete-confirmation
     * dialog opens, never on every class-list render. A dedicated usage-index
     * table is a reasonable future optimization for very large sites.
     */
    public function global_usage_count( string $key ): int {
        global $wpdb;
        $key   = sanitize_key( $key );
        $table = esc_sql( $wpdb->prefix . 'jalpi_pages' );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $count = $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM `{$table}` WHERE page_data LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            '%' . $wpdb->esc_like( $key ) . '%'
        ) );

        return (int) $count;
    }

    // -------------------------------------------------------------------------
    // Export / Import
    // -------------------------------------------------------------------------

    public function export_all(): array {
        return array_map( static function ( $row ) {
            unset( $row['updatedAt'] );
            return $row;
        }, $this->get_all() );
    }

    /**
     * @param array  $classes           Raw class payloads (each should include a 'key' to match against existing classes).
     * @param string $conflict_strategy 'skip' | 'overwrite' | 'duplicate'
     */
    public function import( array $classes, string $conflict_strategy = 'skip' ): array {
        $conflict_strategy = in_array( $conflict_strategy, [ 'skip', 'overwrite', 'duplicate' ], true ) ? $conflict_strategy : 'skip';
        $existing_keys      = wp_list_pluck( $this->get_all(), 'key' );

        $created = 0;
        $updated = 0;
        $skipped = 0;

        foreach ( $classes as $incoming ) {
            if ( ! is_array( $incoming ) ) {
                continue;
            }
            $sanitized = Jalpi_Sanitizer::sanitize_class_definition( $incoming );
            $key       = sanitize_key( $incoming['key'] ?? '' );
            $exists    = $key && in_array( $key, $existing_keys, true );

            if ( $exists && 'skip' === $conflict_strategy ) {
                $skipped++;
                continue;
            }

            if ( $exists && 'overwrite' === $conflict_strategy ) {
                $this->update( $key, $sanitized );
                $updated++;
                continue;
            }

            $this->create( $sanitized['name'], $sanitized );
            $created++;
        }

        return [ 'created' => $created, 'updated' => $updated, 'skipped' => $skipped ];
    }
}
