<?php
namespace Components\Models;

abstract class BaseModel
{
    protected static string $table;       // must be set in subclass
    protected static string $primaryKey;  // must be set in subclass
    // Optional column names used to store JSON meta and cache per record (e.g., 'newsletter_list_meta')
    // Subclasses can set either or both:
    //   protected static ?string $metaColumn  = '...';
    //   protected static ?string $cacheColumn = '...';
    // If $cacheColumn is not defined, it will default to $metaColumn.
    protected static ?string $metaColumn  = null;
    protected static ?string $cacheColumn = null;

    protected array $attributes = [];

    public function __construct(array $data = [])
    {
        $this->fill($data);
    }

    // ---------------- Attribute handling ----------------

    public function fill(array $data): void
    {
        foreach ($data as $k => $v) {
            $this->attributes[$k] = $v;
        }
    }

    public function toArray(): array
    {
        return $this->attributes;
    }

    public function __get(string $name)
    {
        if (method_exists($this, $name)) {
            return $this->$name();
        }
        return $this->attributes[$name] ?? null;
    }

    public function __isset(string $name): bool
    {
        return isset($this->attributes[$name]) || method_exists($this, $name);
    }

    public function __set(string $name, $value): void
    {
        $this->attributes[$name] = $value;
    }

    // ---------------- Per-record JSON meta & cache helpers ----------------

    /**
     * Return the model's JSON META column as an associative array.
     * Column resolution order for backwards compatibility:
     *   1) metaColumn (preferred)
     *   2) cacheColumn (fallback when meta is not defined)
     * If neither column is defined, returns an empty array.
     */
    protected function getMeta(): array
    {
        // When meta & cache resolve to the same physical column, we keep a
        // composite JSON structure: { meta: {...}, cache: {...} }
        // For backward compatibility, a flat JSON is treated as meta.
        return $this->readSection('meta');
    }

    /**
     * Set the model's JSON META column from array and keep attribute in sync (string JSON).
     * If neither meta nor cache column is defined, this is a no-op.
     */
    protected function setMeta(array $meta): void
    {
        $this->writeSection('meta', $meta);
    }

    /**
     * Return the model's JSON CACHE column as an associative array.
     * Column resolution order:
     *   1) cacheColumn (preferred)
     *   2) metaColumn (fallback when cache is not defined)
     */
    protected function getCache(): array
    {
        // See note above in getMeta(); cache uses the composite 'cache' section when shared column is used
        return $this->readSection('cache');
    }

    /**
     * Set the model's JSON CACHE column from array and keep attribute in sync (string JSON).
     * Falls back to meta column when cache column is not defined.
     */
    protected function setCache(array $cache): void
    {
        $this->writeSection('cache', $cache);
    }

    /**
     * Persist only the JSON section ("meta" or "cache") to the database without
     * touching any other columns. This avoids unintended writes to unrelated fields
     * when updating cached UI fragments during GET/HTMX requests.
     *
     * - When meta & cache share the same physical column, this method persists
     *   the full composite JSON previously prepared by writeSection().
     * - Returns true on success, false when no JSON column is configured or in dev mode.
     */
    protected function persistSection(string $section): bool
    {
        if (self::isDev()) return false;
        try {
            $col = $this->resolvedColumnForSection($section);
            if (!$col) { return false; }

            $id = (int)($this->attributes[static::pk()] ?? 0);
            if ($id <= 0) { return false; }

            // Ensure the attribute exists; writeSection() should have already
            // placed a valid JSON string into the attribute for this column.
            $val = $this->attributes[$col] ?? '{}';

            // Perform a narrow update touching only the JSON column
            \mh_db_perform(static::table(), [$col => $val], 'update', static::pk() . " = {$id}");
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Invalidate the JSON cache/meta content.
     * - When $key is provided, unsets only that entry.
     * - When $key is null, clears the entire meta cache.
     * - Supports trailing '%' for prefix matching (e.g. 'flow_html%')
     * If $persist is true, attempts to save() the model.
     */
    public function invalidateCache(?string $key = null, bool $persist = true): bool
    {
        try {
            $col = $this->resolvedColumnForSection('cache');
            if (!$col) { return false; }
            $cache = $this->getCache();
            if ($key === null) {
                $cache = [];
            } else {
                if (strpos($key, '%') !== false) {
                    $prefix = rtrim($key, '%');
                    $changed = false;
                    foreach (array_keys($cache) as $k) {
                        if (strpos($k, $prefix) === 0) {
                            unset($cache[$k]);
                            $changed = true;
                        }
                    }
                    if (!$changed) return true;
                } else {
                    if (array_key_exists($key, $cache)) {
                        unset($cache[$key]);
                    } else {
                        return true;
                    }
                }
            }
            $this->setCache($cache);
            if (!$persist) { return true; }
            // Persist only the JSON cache column to avoid touching unrelated fields
            return $this->persistSection('cache');
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Invalidate the JSON META data. Mirrors invalidateCache() but targets meta data.
     * Supports trailing '%' for prefix matching.
     */
    public function invalidateMeta(?string $key = null, bool $persist = true): bool
    {
        try {
            $col = $this->resolvedColumnForSection('meta');
            if (!$col) { return false; }
            $meta = $this->getMeta();
            if ($key === null) {
                $meta = [];
            } else {
                if (strpos($key, '%') !== false) {
                    $prefix = rtrim($key, '%');
                    $changed = false;
                    foreach (array_keys($meta) as $k) {
                        if (strpos($k, $prefix) === 0) {
                            unset($meta[$k]);
                            $changed = true;
                        }
                    }
                    if (!$changed) return true;
                } else {
                    if (array_key_exists($key, $meta)) {
                        unset($meta[$key]);
                    } else {
                        return true;
                    }
                }
            }
            $this->setMeta($meta);
            if (!$persist) { return true; }
            // Persist only the JSON meta column to avoid touching unrelated fields
            return $this->persistSection('meta');
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Override in subclasses to provide the column name used for JSON META storage.
     */
    protected static function metaColumn(): ?string
    {
        return static::$metaColumn;
    }

    /**
     * Override in subclasses to provide the column name used for JSON CACHE storage.
     * Defaults to metaColumn when not explicitly set.
     */
    protected static function cacheColumn(): ?string
    {
        return static::$cacheColumn ?? static::metaColumn();
    }

    // ---------------- Static helpers ----------------

    /**
     * Public accessor for the resolved physical table name of the model.
     * Resolves the table from a configured constant via mh_cfg(constName) when available,
     * otherwise falls back to defined()/constant() or the literal value.
     */
    public static function table(): string
    {
        // Resolve the table name from a constant via mh_cfg(constName), per guideline.
        // Fallbacks: if mh_cfg is unavailable, use defined()/constant; otherwise use the literal.
        if (function_exists('mh_cfg')) {
            return (string) \mh_cfg(static::$table, static::$table);
        }
        return defined(static::$table) ? constant(static::$table) : static::$table;
    }

    public static function pk(): string
    {
        return static::$primaryKey;
    }

    /**
     * Override in subclasses to mark the model as read-only.
     */
    public static function isReadOnly(): bool
    {
        return false;
    }

    protected static function isDev(): bool
    {
        if (defined('BEEZ_UI_DEV') && BEEZ_UI_DEV === true) {
            return true;
        }
        return false;
    }

    /**
     * Returns a human-friendly, localized read-only message.
     *
     * Lookup order:
     *   1) UI_<MODELCLASS>_READ_ONLY (e.g. UI_MANUFACTURER_READ_ONLY)
     *   2) fallback string
     */
    protected function readOnlyMessage(): string
    {
        $fallback = 'This resource is read-only.';
        if (!function_exists('mh_lng')) {
            return $fallback;
        }

        $class = static::class;
        if (strpos($class, '\\') !== false) {
            $parts = explode('\\', $class);
            $class = (string)end($parts);
        }
        $lngKey = 'UI_' . strtoupper($class) . '_READ_ONLY';

        return (string)\mh_lng($lngKey, $fallback);
    }

    /**
     * Throws when attempting write operations on read-only models.
     */
    protected function assertWritable(): void
    {
        if (static::isReadOnly()) {
            throw new \RuntimeException($this->readOnlyMessage());
        }
    }

    // ---------------- Internal helpers ----------------

    /**
     * Resolve the physical column used for a logical section ('meta' or 'cache').
     * Applies the same fallback rules used by the public API.
     */
    private function resolvedColumnForSection(string $section): ?string
    {
        if ($section === 'meta') {
            return static::metaColumn() ?: static::cacheColumn();
        }
        if ($section === 'cache') {
            return static::cacheColumn() ?: static::metaColumn();
        }
        return null;
    }

    /**
     * Returns true if meta and cache resolve to the same physical column.
     * In that case we store a composite JSON: { meta: {...}, cache: {...} }.
     */
    private function usesCompositeJson(): bool
    {
        $m = $this->resolvedColumnForSection('meta');
        $c = $this->resolvedColumnForSection('cache');
        return ($m !== null) && ($c !== null) && ($m === $c);
    }

    /**
     * Read a logical section (meta/cache) from storage, handling composite JSON and legacy flat payloads.
     */
    private function readSection(string $section): array
    {
        $section = ($section === 'cache') ? 'cache' : 'meta';
        $col = $this->resolvedColumnForSection($section);
        if (!$col) { return []; }

        if ($this->usesCompositeJson()) {
            $raw = $this->readJsonColumn($col);
            // If it already is a composite structure, extract section
            if (array_key_exists('meta', $raw) || array_key_exists('cache', $raw)) {
                $value = $raw[$section] ?? [];
                return is_array($value) ? $value : [];
            }
            // Legacy flat payload: treat it as meta; cache is empty
            if ($section === 'meta') {
                return is_array($raw) ? $raw : [];
            }
            return [];
        }

        // Separate columns scenario
        return $this->readJsonColumn($col);
    }

    /**
     * Write a logical section (meta/cache) preserving the other section when using a shared column.
     * Migrates legacy flat payloads to composite structure transparently.
     */
    private function writeSection(string $section, array $payload): void
    {
        $section = ($section === 'cache') ? 'cache' : 'meta';
        $col = $this->resolvedColumnForSection($section);
        if (!$col) { return; }

        if ($this->usesCompositeJson()) {
            $raw = $this->readJsonColumn($col);
            $metaExisting = [];
            $cacheExisting = [];

            if (array_key_exists('meta', $raw) || array_key_exists('cache', $raw)) {
                $metaExisting = is_array($raw['meta'] ?? null) ? $raw['meta'] : [];
                $cacheExisting = is_array($raw['cache'] ?? null) ? $raw['cache'] : [];
            } else {
                // Legacy flat payload stored previously: treat it as meta, cache empty
                $metaExisting = is_array($raw) ? $raw : [];
                $cacheExisting = [];
            }

            if ($section === 'meta') {
                $metaExisting = $payload;
            } else {
                $cacheExisting = $payload;
            }
            $this->writeJsonColumn($col, [
                'meta' => $metaExisting,
                'cache' => $cacheExisting,
            ]);
            return;
        }

        // Separate columns scenario
        $this->writeJsonColumn($col, $payload);
    }

    /**
     * Safely read a JSON column from $this->attributes and return an array.
     */
    private function readJsonColumn(?string $col): array
    {
        try {
            if (!$col) { return []; }
            $raw = $this->attributes[$col] ?? '';
            if (is_array($raw)) { return $raw; }
            if (!is_string($raw) || trim($raw) === '') { return []; }
            $data = json_decode($raw, true);
            return is_array($data) ? $data : [];
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * Safely write an array to a JSON column into $this->attributes.
     */
    private function writeJsonColumn(?string $col, array $data): void
    {
        try {
            if (!$col) { return; }
            $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            $this->attributes[$col] = $json;
        } catch (\Throwable $e) {
            // no-op on failure
        }
    }

    // ---------------- Finders ----------------

    /**
     * Find a single record by its primary key.
     *
     * @param int $id
     * @return static|null
     */
    public static function find(int $id)
    {
        if (self::isDev()) return null;

        $sql = "SELECT * FROM " . static::table() . " WHERE " . static::pk() . " = " . (int)$id . " LIMIT 1";
        $q = \mh_db_query($sql);
        $row = \mh_db_fetch_array($q);
        \mh_db_free_result($q);

        return $row ? new static($row) : null;
    }

    public static function all(): array
    {
        if (self::isDev()) return [];

        $sql = "SELECT * FROM " . static::table() . " ORDER BY " . static::pk() . " DESC";
        $q = \mh_db_query($sql);

        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new static($row);
        }
        \mh_db_free_result($q);

        return $items;
    }

    /**
     * Simple pagination helper.
     * @param int $perPage
     * @param int $page
     * @param string $search
     * @param array $searchCols
     * @param array $filters Key-value pairs for additional WHERE clauses (exact match)
     * @return array{items: static[], total: int}
     */
    public static function paginate(int $perPage = 10, int $page = 1, string $search = '', array $searchCols = [], array $filters = []): array
    {
        if (self::isDev()) return ['items' => [], 'total' => 0];

        $where = "1=1";
        if ($search !== '' && !empty($searchCols)) {
            $clauses = [];
            foreach ($searchCols as $col) {
                $clauses[] = $col . " LIKE '%" . \mh_db_input($search) . "%'";
            }
            $where .= " AND (" . implode(" OR ", $clauses) . ")";
        }

        if (!empty($filters)) {
            foreach ($filters as $col => $val) {
                if ($val === '' || $val === null) continue;
                $where .= " AND " . \mh_db_input($col) . " = '" . \mh_db_input((string)$val) . "'";
            }
        }

        $countSql = "SELECT COUNT(*) as total FROM " . static::table() . " WHERE " . $where;
        $cq = \mh_db_query($countSql);
        $res = \mh_db_fetch_array($cq);
        $total = (int)($res['total'] ?? 0);
        \mh_db_free_result($cq);

        $offset = max(0, ($page - 1) * $perPage);

        $sql = "SELECT * FROM " . static::table() . " WHERE " . $where . " ORDER BY " . static::pk() . " DESC LIMIT " . (int)$offset . ", " . (int)$perPage;
        $q = \mh_db_query($sql);

        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new static($row);
        }
        \mh_db_free_result($q);

        return [
            'items' => $items,
            'total' => $total,
        ];
    }

    // ---------------- Persistence ----------------

    /**
     * Hook: subclasses may override to validate before save.
     *
     * @param array $data Normalized persistence payload
     * @param array $submitted Full submitted payload/context
     * @param array $context Additional context (mode, id, post, existing)
     * @return array Validation errors by field: [fieldName => ['Message', ...]]
     */
    public function validateBeforeSave(array $data, array $submitted = [], array $context = []): array
    {
        if (!static::isReadOnly()) {
            return [];
        }

        $field = array_key_first($data);
        if (!is_string($field) || $field === '') {
            $field = array_key_first($submitted);
        }
        if (!is_string($field) || $field === '') {
            $field = static::pk();
        }

        return [
            $field => [$this->readOnlyMessage()],
        ];
    }

    public function save(): bool
    {
        $this->assertWritable();
        return isset($this->attributes[static::pk()])
            ? $this->update()
            : $this->insert();
    }

    protected function insert(): bool
    {
        $this->assertWritable();
        if (self::isDev()) return false;

        $data = $this->attributes;
        unset($data[static::pk()]);
        // Allow subclasses to filter/normalize attributes prior to save
        $data = $this->filterAttributesForSave($data);

        \mh_db_perform(static::table(), $data);
        $this->attributes[static::pk()] = \mh_db_insert_id();
        return true;
    }

    protected function update(): bool
    {
        $this->assertWritable();
        if (self::isDev()) return false;

        $data = $this->attributes;
        $id = (int)$data[static::pk()];
        unset($data[static::pk()]);
        // Allow subclasses to filter/normalize attributes prior to save
        $data = $this->filterAttributesForSave($data);

//        dd([
//            static::table(),
//            $data,
//            static::pk() . " = {$id}",
//        ]);

        \mh_db_perform(static::table(), $data, 'update', static::pk() . " = {$id}");
        return true;
    }

    /**
     * Hook: subclasses may override to modify/clean the attribute set before saving to the DB.
     * Default: passthrough.
     * @param array $data
     * @return array
     */
    protected function filterAttributesForSave(array $data): array
    {
        // Normalize JSON columns (meta/cache) to always contain valid JSON strings.
        // Prevent invalid empty strings ('') from being written to JSON-typed columns.
        try {
            $jsonCols = [];
            $m = $this->resolvedColumnForSection('meta');
            $c = $this->resolvedColumnForSection('cache');
            if ($m) { $jsonCols[$m] = true; }
            if ($c) { $jsonCols[$c] = true; }

            foreach (array_keys($jsonCols) as $col) {
                if (!array_key_exists($col, $data)) {
                    continue;
                }
                $v = $data[$col];

                // Arrays -> encode to JSON
                if (is_array($v)) {
                    $data[$col] = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                    continue;
                }

                // Null or empty string -> store empty object
                if ($v === null || (is_string($v) && trim($v) === '')) {
                    $data[$col] = '{}';
                    continue;
                }

                // Strings: validate JSON; fallback to '{}'
                if (is_string($v)) {
                    $trim = trim($v);
                    // Accept the literal JSON null
                    if (strtolower($trim) === 'null') {
                        // Keep as null JSON value; let DB decide nullability if column allows it.
                        $data[$col] = 'null';
                        continue;
                    }
                    $decoded = json_decode($trim, true);
                    if (!is_array($decoded) && $decoded !== null) {
                        // Valid non-object/array JSON (e.g., number/boolean) – wrap into object for safety
                        $data[$col] = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                        continue;
                    }
                    if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
                        $data[$col] = '{}';
                        continue;
                    }
                    // Ensure we persist compact JSON
                    $data[$col] = json_encode($decoded ?? new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                    continue;
                }

                // Fallback: encode scalars/objects
                $data[$col] = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            }
        } catch (\Throwable $e) {
            // best-effort: on any error, leave $data as-is
        }

        return $data;
    }

    public function delete(): bool
    {
        $this->assertWritable();
        if (self::isDev()) return false;

        $id = (int)$this->attributes[static::pk()];
        \mh_db_query("DELETE FROM " . static::table() . " WHERE " . static::pk() . " = {$id}");
        return true;
    }

    // ---------------- Relations ----------------

    public function hasMany(string $relatedClass, ?string $foreignKey = null, ?string $localKey = null): array
    {
        if (self::isDev()) return [];

        $localKey = $localKey ?: static::pk();
        $foreignKey = $foreignKey ?: $localKey; // convention: same key name in related table
        $localValue = (int)($this->attributes[$localKey] ?? 0);

        $sql = "SELECT * FROM " . $relatedClass::table() . " WHERE {$foreignKey} = {$localValue}";
        $q = \mh_db_query($sql);

        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new $relatedClass($row);
        }
        \mh_db_free_result($q);

        return $items;
    }

    public function belongsTo(string $relatedClass, string $foreignKey, ?string $ownerKey = null): ?object
    {
        $ownerKey = $ownerKey ?: $relatedClass::pk();
        $id = (int)($this->attributes[$foreignKey] ?? 0);

        return $id > 0 ? $relatedClass::find($id) : null;
    }
}