<?php

namespace CliqThemes\ORM\Form;

use CliqThemes\ORM\Model;
use CliqThemes\ORM\Form\StorageStrategy;
use InvalidArgumentException;

/**
 * Coordinates persisting complex form payloads across multiple storage targets.
 */
class SchemaPersistenceCoordinator
{
    /** @var class-string<Model> */
    protected string $modelClass;

    protected FieldStorageMap $storageMap;

    protected StorageStrategyRegistry $registry;

    /**
     * @param class-string<Model> $modelClass
     */
    public function __construct(string $modelClass, FieldStorageMap $storageMap, StorageStrategyRegistry $registry)
    {
        if (!is_subclass_of($modelClass, Model::class)) {
            throw new InvalidArgumentException(sprintf('The persistence coordinator expects a model class extending %s.', Model::class));
        }

        $this->modelClass = $modelClass;
        $this->storageMap = $storageMap;
        $this->registry = $registry;
    }

    /**
     * Return the model class used for persistence operations.
     *
     * @return class-string<Model>
     */
    public function getModelClass(): string
    {
        return $this->modelClass;
    }

    /**
     * Persist the provided payload for the given model identifier.
     *
     * @param array<string, mixed> $payload
     */
    public function saveOrUpdate(int $modelId, array $payload): void
    {
        $model = $this->resolveModel($modelId);
        $context = ['model' => $model];
        $resolvedStrategies = [];

        foreach ($payload as $field => $value) {
            if (!$this->storageMap->has($field)) {
                continue;
            }

            $descriptor = $this->storageMap->storageFor($field);
            $strategy = $this->resolveStrategy($descriptor, $resolvedStrategies, $context);

            if ($value === null) {
                $strategy->delete($modelId, $field);
                continue;
            }

            $strategy->saveOrUpdate($modelId, $field, $value);
        }

        if ($model->isDirty()) {
            $model->save();
        }
    }

    /**
     * Retrieve the stored values for all registered fields.
     *
     * @return array<string, mixed>
     */
    public function hydrate(int $modelId): array
    {
        $model = $this->findModel($modelId) ?? $this->instantiateModel($modelId);
        $context = ['model' => $model];
        $resolvedStrategies = [];
        $data = [];

        foreach ($this->storageMap->descriptors() as $descriptor) {
            $strategy = $this->resolveStrategy($descriptor, $resolvedStrategies, $context);
            $data[$descriptor->field] = $strategy->get($modelId, $descriptor->field);
        }

        return $data;
    }

    /**
     * Retrieve stored values for multiple models keyed by their identifier.
     *
     * @param int[] $modelIds
     *
     * @return array<int, array<string, mixed>>
     */
    public function list(array $modelIds): array
    {
        $results = [];

        foreach ($modelIds as $modelId) {
            $modelId = (int) $modelId;
            $model = $this->findModel($modelId) ?? $this->instantiateModel($modelId);
            $context = ['model' => $model];
            $resolvedStrategies = [];
            $data = [];

            foreach ($this->storageMap->descriptors() as $descriptor) {
                $strategy = $this->resolveStrategy($descriptor, $resolvedStrategies, $context);
                $data[$descriptor->field] = $strategy->get($modelId, $descriptor->field);
            }

            $results[$modelId] = $data;
        }

        return $results;
    }

    /**
     * Batched hydration for LIST views: load every non-base field for MANY models
     * with a fixed number of queries instead of one-per-field-per-model (the N+1
     * that makes a grid fire hundreds of queries). Base columns are intentionally
     * skipped — they already live on each model's own row, which list callers
     * already have. Strategies that expose getMany() are batched; any other
     * strategy falls back to its per-row get(), so behaviour is unchanged.
     *
     * @param int[] $modelIds
     *
     * @return array<int, array<string, mixed>>
     */
    public function hydrateMany(array $modelIds): array
    {
        $ids = array_values(array_unique(array_map('intval', $modelIds)));
        $out = array_fill_keys($ids, []);
        if ($ids === []) {
            return $out;
        }

        $cache = [];
        foreach ($this->storageMap->descriptors() as $descriptor) {
            // Base columns are on the model's own row (present in the list result).
            if ($this->isBaseTarget($descriptor->target)) {
                continue;
            }

            // Pivot/meta/table/json strategies build from descriptor options and
            // don't need a model in context (only the base passthrough does).
            $strategy = $this->resolveStrategy($descriptor, $cache, []);

            if (method_exists($strategy, 'getMany')) {
                $values = $strategy->getMany($ids, $descriptor->field);
                foreach ($ids as $id) {
                    $out[$id][$descriptor->field] = $values[$id] ?? null;
                }
            } else {
                foreach ($ids as $id) {
                    $out[$id][$descriptor->field] = $strategy->get($id, $descriptor->field);
                }
            }
        }

        return $out;
    }

    /**
     * Create a new record: persist base columns to obtain an identifier, then
     * persist the remaining (pivot/json/meta/etc.) fields.
     *
     * @param array<string, mixed> $payload
     * @param array<string, mixed> $context
     *
     * @return array<string, mixed>
     */
    public function create(array $payload, array $context = []): array
    {
        $model = $this->instantiateModel();

        foreach ($payload as $field => $value) {
            if ($this->storageMap->has($field) && $this->isBaseTarget($this->storageMap->storageFor($field)->target)) {
                $model->setAttribute($field, $value);
            }
        }

        $model->save();
        $modelId = (int) $model->getKey();

        $this->saveOrUpdate($modelId, $payload);

        return $this->record($modelId);
    }

    /**
     * Retrieve a single record (hydrated fields + primary key).
     *
     * @param array<string, mixed> $context
     *
     * @return array<string, mixed>
     */
    public function retrieve(int|string $modelId, array $context = []): array
    {
        return $this->record((int) $modelId);
    }

    /**
     * Update an existing record and return its hydrated representation.
     *
     * @param array<string, mixed> $payload
     * @param array<string, mixed> $context
     *
     * @return array<string, mixed>
     */
    public function update(int|string $modelId, array $payload, array $context = []): array
    {
        $modelId = (int) $modelId;
        $this->saveOrUpdate($modelId, $payload);

        return $this->record($modelId);
    }

    /**
     * Delete a record and clean up its non-base storage (pivots, meta, json).
     *
     * @param array<string, mixed> $context
     */
    public function delete(int|string $modelId, array $context = []): bool
    {
        $modelId = (int) $modelId;
        $model = $this->resolveModel($modelId);
        $strategyContext = ['model' => $model];
        $cache = [];

        foreach ($this->storageMap->descriptors() as $descriptor) {
            // Base columns and json values live on the model's own row and are
            // removed when it is deleted; only separate-row storages (pivots,
            // meta, side tables) need explicit cleanup here.
            if ($this->isBaseTarget($descriptor->target) || $descriptor->target === 'json') {
                continue;
            }

            $strategy = $this->resolveStrategy($descriptor, $cache, $strategyContext);
            $strategy->delete($modelId, $descriptor->field);
        }

        $found = $this->findModel($modelId);
        if ($found instanceof Model) {
            $found->delete();
        }

        return true;
    }

    /**
     * Whether a storage target writes directly to the model's own columns.
     */
    protected function isBaseTarget(string $target): bool
    {
        return $target === 'passthrough' || $target === 'base';
    }

    /**
     * Build a record array (hydrated fields + primary key) for a model id.
     *
     * @return array<string, mixed>
     */
    protected function record(int $modelId): array
    {
        $data = $this->hydrate($modelId);
        $data[$this->instantiateModel()->getPrimaryKey()] = $modelId;
        $data['id'] = $modelId;

        return $data;
    }

    /**
     * Resolve or reuse a storage strategy for the descriptor.
     *
     * @param array<string, StorageStrategy> $cache
     * @param array<string, mixed> $context
     */
    protected function resolveStrategy(FieldStorageDescriptor $descriptor, array &$cache, array $context): StorageStrategy
    {
        $key = $this->buildStrategyCacheKey($descriptor);

        if (!isset($cache[$key])) {
            $cache[$key] = $this->registry->resolve($descriptor->target, $descriptor, $context);
        }

        return $cache[$key];
    }

    /**
     * Build a cache key to reuse storage strategies with identical configuration.
     */
    protected function buildStrategyCacheKey(FieldStorageDescriptor $descriptor): string
    {
        return $descriptor->target . ':' . md5(json_encode($descriptor->options));
    }

    /**
     * Resolve the model instance for the supplied identifier.
     */
    protected function resolveModel(int $modelId): Model
    {
        return $this->findModel($modelId) ?? $this->instantiateModel($modelId);
    }

    /**
     * Attempt to locate the persisted model instance.
     */
    protected function findModel(int $modelId): ?Model
    {
        /** @var class-string<Model> $modelClass */
        $modelClass = $this->modelClass;

        if (!method_exists($modelClass, 'find')) {
            return null;
        }

        $model = $modelClass::find($modelId);

        return $model instanceof Model ? $model : null;
    }

    /**
     * Instantiate a new model instance seeded with the provided identifier.
     */
    protected function instantiateModel(?int $modelId = null): Model
    {
        $modelClass = $this->modelClass;
        /** @var Model $model */
        $model = new $modelClass();

        if ($modelId !== null) {
            $primaryKey = $model->getPrimaryKey();
            $model->setAttribute($primaryKey, $modelId);
        }

        return $model;
    }
}
