<?php

namespace apexl\Io\operators;

use apexl\Io\exceptions\RecordNotFoundException;
use apexl\Vault\enums\SortDirection;
use apexl\Vault\exceptions\ExecutionException;
use apexl\Vault\operators\drivers\mongodb;
use Exception;

/**
 * Operator base for entities stored in a MongoDB-backed named vault.
 *
 * Wraps the normal entityDatabaseOperator + replaces the SQL-shaped
 * machinery that doesn't fit Mongo's schemaless model:
 *
 *   store()           — skip the column-validation pass (no schema to
 *                       validate against; data ships as-is to Mongo)
 *   totalEntities()   — use the typed toCountQuery() path rather than
 *                       the SQL count() projection string
 *   entityFields()    — return [] (Mongo collections expose no static
 *                       column list; consumers should inspect docs)
 *   loadBy()          — add the useOperatorVault swap that the parent
 *                       method is missing (parent bug; safer to override
 *                       than to edit the parent and risk SQL paths)
 *   getLastNewRecord() — coerce false → [] for empty collections
 *                       (parent's return type is `array`; fetchAssoc
 *                       returns false on no rows)
 *
 * Plus exposes a typed mongo() accessor for Mongo-only escape hatches
 * (pipeline/aggregate/ensureIndex/dropIndex/bulkWrite) — consumers that
 * need cross-collection $lookup, schemaless aggregations, or index
 * management reach for it directly:
 *
 *   $this->mongo()->pipeline($this->dbTable, [['$group' => ...]]);
 *
 * Use this base when the entity's operatorVault config entry points
 * at a 'driver' => 'mongodb' vault. Use the SQL entityDatabaseOperator
 * for everything else.
 */
class mongoEntityDatabaseOperator extends entityDatabaseOperator
{
    /**
     * Typed accessor for the mongodb driver's escape-hatch methods.
     * Asserts the resolved vault really is mongodb — catches config
     * drift early (e.g. operatorVault points at a SQL vault by mistake).
     */
    public function mongo(): mongodb
    {
        $this->useOperatorVault();
        $driver = $this->vault->{$this->vault->getDefaultVault()};
        $this->useOperatorVault(true);
        if (!$driver instanceof mongodb) {
            throw new Exception(sprintf(
                'mongoEntityDatabaseOperator expected a `mongodb` driver for vault `%s`; got %s. '
                . 'Check the operatorVault config entry has driver=>"mongodb".',
                $this->operatorVault ?: $this->vault->getDefaultVault(),
                is_object($driver) ? get_class($driver) : gettype($driver),
            ));
        }
        return $driver;
    }

    /**
     * Schemaless storage — no column-validation pass. Otherwise mirrors
     * parent: fires the same pre/post hooks; handles insert-vs-update
     * via primary-key presence + cleanLoad probe.
     *
     * @throws Exception
     */
    public function store(array $data): bool|array
    {
        $this->useOperatorVault();
        $meta = null;
        if (empty($data)) {
            throw new Exception('Cannot store empty entity data');
        }
        if (isset($data['_metadata'])) {
            $meta = $data['_metadata'];
            unset($data['_metadata']);
        }

        // Schemaless — no $extraStore split. Everything ships to Mongo.
        $extraStore = [];

        $entity = [];
        if (isset($data[$this->primaryKey]) && !empty($data[$this->primaryKey])) {
            $entity = $this->cleanLoad($data[$this->primaryKey]);
        }
        if (!empty($entity['data'])) {
            $data = $this->processHookCompat(
                'preEntityUpdate',
                $data,
                $this->dbTable,
                $extraStore,
                $this->extends,
            );
            $this->useOperatorVault();
            // Strip the primary key from the update payload — it's only
            // needed for the where() target, not the set. Also strip
            // Mongo's native `_id` which load() returned alongside our
            // `id` counter; Mongo refuses to "modify the immutable field
            // _id" even when set to its existing value.
            $updateFields = $data;
            unset($updateFields[$this->primaryKey], $updateFields['_id']);
            $this->vault->update($this->dbTable)->fields($updateFields)->where(
                $this->primaryKey,
                $data[$this->primaryKey],
            )->execute();
            $this->useOperatorVault(true);
            $this->processHookCompat('postEntityUpdate', $data, $this->dbTable, $data[$this->primaryKey]);
            return true;
        }

        $data = $this->processHookCompat(
            'preEntityInsert',
            $data,
            $this->dbTable,
            $extraStore,
            $this->extends,
        );
        $this->useOperatorVault();
        $this->vault->insert($this->dbTable)->fields($data)->execute();
        $lastStored = $this->getLastNewRecord(true);
        $this->useOperatorVault(true);

        $updatedData = $this->load($lastStored[$this->primaryKey], true)['data'] ?? $data;
        if ($meta) {
            $updatedData['_metadata'] = $meta;
        }
        $this->useOperatorVault(true);
        $this->processHookCompat(
            sprintf('postEntityInsert:%s', $this->dbTable),
            $data,
            $lastStored[$this->primaryKey] ?? null,
        );
        $this->processHookCompat('postEntityInsert', $data, $this->dbTable, $lastStored[$this->primaryKey] ?? null);

        return ['updateEntityData' => true, 'data' => $updatedData];
    }

    /**
     * Use the typed toCountQuery() path — countDocuments under the
     * hood — rather than the SQL "count(table.id) AS count" projection
     * string the parent passes. Mongo driver's fields() doesn't accept
     * a string + the count concept doesn't carry over.
     *
     * @throws ExecutionException
     */
    public function totalEntities($conditions = []): int
    {
        $this->useOperatorVault();
        $query = $this->vault->select($this->dbTable);
        if (!empty($conditions)) {
            foreach ($conditions as $condition) {
                $operator = $condition[2] ?? '=';
                $type = $condition[3] ?? 'AND';
                $query->where($condition[0], $condition[1], $operator, $type);
            }
        }
        $query->toCountQuery();
        $result = $query->execute()->fetchAssoc();
        $this->useOperatorVault(true);
        return (int) ($result['count'] ?? 0);
    }

    /**
     * Mongo collections have no static column list. Consumers wanting
     * to inspect document shapes should sample via the typed mongo()
     * accessor (`mongo()->aggregate($coll, [['$sample' => ['size' => 1]]])`).
     *
     * @return list<\apexl\Io\dto\EntityField>
     */
    public function entityFields(): array
    {
        return [];
    }

    /**
     * Parent's loadBy() doesn't call useOperatorVault — for SQL consumers
     * that's benign (single default vault), but for any operatorVault
     * declarer it silently hits the wrong DB. Override to swap properly.
     *
     * @throws RecordNotFoundException
     */
    public function loadBy(string $field, mixed $value, bool $skipAccessCheck = false): array
    {
        $this->useOperatorVault();
        $exception = null;
        try {
            $record = $this->vault
                ->select($this->dbTable)
                ->fields([$this->primaryKey])
                ->where($field, $value)
                ->execute()->fetch();

            if ($record) {
                $loaded = $this->load($record->{$this->primaryKey}, $skipAccessCheck);
                $this->useOperatorVault(true);
                return $loaded;
            }
        } catch (Exception $exception) {
        }
        $this->useOperatorVault(true);

        throw new RecordNotFoundException(
            sprintf(
                'No record found for %s with [%s => %s]',
                $this::class,
                $field,
                is_scalar($value) ? (string) $value : gettype($value),
            ),
            $exception ? $exception->getCode() : 404,
            $exception,
        );
    }

    /**
     * Translate a LoaderConditionCollection into Mongo where() chain
     * + execute. Bypasses the vendor's SQL-coupled EntityFinderService
     * for Mongo-backed entities — those Entity subclasses override
     * Entity::_findMultiple() to call this helper instead.
     *
     * Handles the LoaderCondition shape directly (public column /
     * operator / value / type). Strips the `(column)` paren-wrap that
     * LoaderCondition::applyToQuery() adds, since Mongo's filter
     * builder doesn't recognise the SQL convention.
     *
     * @param  \apexl\Io\collections\LoaderConditionCollection|list<\apexl\Io\dto\LoaderCondition>  $conditions
     * @param  ?int                                                                                  $limit
     * @param  int                                                                                   $offset
     * @return list<array<string,mixed>>
     */
    public function loadByConditions(
        $conditions = [],
        ?int $limit = null,
        int $offset = 0,
        ?string $orderByField = null,
        \apexl\Vault\enums\SortDirection $orderByDirection = \apexl\Vault\enums\SortDirection::DESC,
    ): array {
        $this->useOperatorVault();
        $query = $this->vault->select($this->dbTable);

        $iter = is_array($conditions) ? $conditions : iterator_to_array($conditions);
        foreach ($iter as $c) {
            if (!$c instanceof \apexl\Io\dto\LoaderCondition) {
                continue;
            }
            // Strip LoaderCondition's `(col)` wrap if present
            $col = trim($c->column);
            if (str_starts_with($col, '(') && str_ends_with($col, ')')) {
                $col = substr($col, 1, -1);
            }
            $query->where($col, $c->value, $c->operator);
        }

        if ($orderByField !== null) {
            $query->orderBy($orderByField, $orderByDirection);
        }
        if ($limit !== null) {
            $query->limit($limit, max(0, $offset));
        }

        $rows = $query->execute()->fetchAllAssoc();
        $this->useOperatorVault(true);
        return $rows ?: [];
    }

    /**
     * Parent returns false (from fetchAssoc) when the collection is
     * empty, but the method's return-type is `array`. Coerce to [].
     */
    public function getLastNewRecord(bool $skipAccess = false): array
    {
        $this->useOperatorVault();
        $query = $this->vault->select($this->dbTable);
        $result = $query
            ->orderBy($this->primaryKey, SortDirection::DESC)
            ->limit(1)
            ->execute()
            ->fetchAssoc();
        $this->useOperatorVault(true);
        return is_array($result) ? $result : [];
    }

    /**
     * processHook is private on parent — but the hook surface is the same.
     * Wrap to avoid duplicating its signature for now; revisit if Mongo
     * operators need divergent hook behaviour.
     */
    private function processHookCompat(string $hook, mixed ...$args): mixed
    {
        // Re-resolve from container each call — parent's $hookManager is
        // private, so we can't reach it directly. Slightly more expensive
        // than the parent's instance field but matches the existing
        // container-based bootstrap and avoids changing the parent.
        return \apexl\Io\container()->get(\apexl\Io\includes\HookManager::class)
            ->processHook($hook, ...$args);
    }
}
