<?php
declare(strict_types=1);

namespace apexl\Vault\Tests;

use apexl\Vault\enums\SortDirection;
use apexl\Vault\enums\WhereCondition;
use apexl\Vault\exceptions\MongoUnsupportedException;
use apexl\Vault\operators\drivers\mongodb;
use MongoDB\Client;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use stdClass;

/**
 * mongodb driver — 3.1.0 read + write path coverage. Promoted from
 * the Z1.d / Z2 smoke scripts during Z1.e.
 *
 * Skipped automatically when VAULT_TEST_MONGO_URI is empty (e.g. CI
 * without a mongo service).
 *
 * Per-test fixture cleanup drops the seed collection + counter doc.
 * Tests reuse a single mongodb instance constructed in setUp.
 */
final class MongodbDriverTest extends TestCase
{
    private const string COLLECTION = 'phpunit_seed';

    private mongodb $drv;
    private Client $client;
    private string $database;

    protected function setUp(): void
    {
        $uri = (string) ($_ENV['VAULT_TEST_MONGO_URI'] ?? '');
        $this->database = (string) ($_ENV['VAULT_TEST_MONGO_DB'] ?? 'vault_phpunit');
        if ($uri === '') {
            $this->markTestSkipped('VAULT_TEST_MONGO_URI not set');
        }

        $cfg = new stdClass();
        $cfg->driver   = 'mongodb';
        $cfg->uri      = $uri;
        $cfg->database = $this->database;

        $this->drv = new mongodb($cfg);
        $this->client = $this->drv->getConnection();
        $this->resetState();
    }

    protected function tearDown(): void
    {
        if (isset($this->client)) {
            $this->resetState();
        }
    }

    private function resetState(): void
    {
        $db = $this->client->selectDatabase($this->database);
        $db->selectCollection(self::COLLECTION)->drop();
        $db->selectCollection(mongodb::COUNTERS_COLLECTION)
            ->deleteOne(['_id' => self::COLLECTION]);
    }

    /** Seed the standard 5-row read-path fixture. */
    private function seedReadFixture(): void
    {
        $this->client->selectDatabase($this->database)
            ->selectCollection(self::COLLECTION)
            ->insertMany([
                ['id' => 1, 'name' => 'Alpha',   'priority' => 1, 'tag' => 'red',   'value' => 10],
                ['id' => 2, 'name' => 'Bravo',   'priority' => 2, 'tag' => 'blue',  'value' => 20],
                ['id' => 3, 'name' => 'Charlie', 'priority' => 3, 'tag' => 'red',   'value' => 30],
                ['id' => 4, 'name' => 'Delta',   'priority' => 2, 'tag' => 'green', 'value' => 40, 'deleted' => null],
                ['id' => 5, 'name' => 'Echo',    'priority' => 1, 'tag' => 'blue',  'value' => 50, 'deleted' => 1779000000],
            ]);
    }

    // ─── Z1.d — read path ───

    #[Test]
    public function selects_all_rows(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)->execute()->fetchAllAssoc();
        self::assertCount(5, $rows);
    }

    #[Test]
    public function where_equals_filters_correctly(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)->where('tag', 'red')->execute()->fetchAllAssoc();
        self::assertCount(2, $rows);
    }

    #[Test]
    public function where_greater_than_filters_correctly(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('value', 25, WhereCondition::GREATER_THAN)
            ->execute()->fetchAllAssoc();
        self::assertCount(3, $rows);
    }

    #[Test]
    public function where_is_null_matches_absent_and_literal_null(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('deleted', null, WhereCondition::IS_NULL)
            ->execute()->fetchAllAssoc();
        self::assertCount(4, $rows, 'absent + literal null both match SQL IS NULL semantics');
    }

    #[Test]
    public function where_is_not_null_matches_only_set_values(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('deleted', null, WhereCondition::IS_NOT_NULL)
            ->execute()->fetchAllAssoc();
        self::assertCount(1, $rows);
    }

    #[Test]
    public function where_in_with_array(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('id', [1, 3, 5], WhereCondition::IN)
            ->execute()->fetchAllAssoc();
        self::assertCount(3, $rows);
    }

    #[Test]
    public function where_not_in_with_array(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('tag', ['red', 'green'], WhereCondition::NOT_IN)
            ->execute()->fetchAllAssoc();
        self::assertCount(2, $rows);
    }

    #[Test]
    public function and_chain_via_multiple_where(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('tag', 'blue')
            ->where('value', 30, WhereCondition::GREATER_THAN)
            ->execute()->fetchAllAssoc();
        self::assertCount(1, $rows);
        self::assertSame('Echo', $rows[0]['name']);
    }

    #[Test]
    public function or_chain_via_orWhere(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('tag', 'red')
            ->orWhere('tag', 'green')
            ->execute()->fetchAllAssoc();
        self::assertCount(3, $rows);
    }

    #[Test]
    public function order_by_desc_with_limit(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->orderBy('value', SortDirection::DESC)
            ->limit(2)
            ->execute()->fetchAllAssoc();
        self::assertCount(2, $rows);
        self::assertSame(50, $rows[0]['value']);
        self::assertSame(40, $rows[1]['value']);
    }

    #[Test]
    public function limit_with_offset(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->orderBy('id', SortDirection::ASC)
            ->limit(2, 2)
            ->execute()->fetchAllAssoc();
        self::assertSame([3, 4], array_column($rows, 'id'));
    }

    #[Test]
    public function projection_hides_id_when_narrowed(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->fields(['id', 'name'])
            ->orderBy('id', SortDirection::ASC)
            ->limit(1)
            ->execute()->fetchAllAssoc();
        self::assertSame(['id', 'name'], array_keys($rows[0]));
    }

    #[Test]
    public function to_count_query_returns_count(): void
    {
        $this->seedReadFixture();
        $this->drv->select(self::COLLECTION)->where('tag', 'red')->toCountQuery()->execute();
        self::assertSame(2, $this->drv->fetchColumn(0));
        self::assertTrue($this->drv->isCount());
    }

    #[Test]
    public function sequential_fetch_walker_returns_false_at_end(): void
    {
        $this->seedReadFixture();
        $this->drv->select(self::COLLECTION)->orderBy('id', SortDirection::ASC)->limit(3)->execute();
        self::assertSame(1, $this->drv->fetch()->id);
        self::assertSame(2, $this->drv->fetch()->id);
        self::assertSame(3, $this->drv->fetch()->id);
        self::assertFalse($this->drv->fetch());
    }

    #[Test]
    public function or_where_multiple_creates_or_group(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('priority', 1)
            ->orWhereMultiple([['tag', 'green'], ['value', 20]])
            ->execute()->fetchAllAssoc();
        // priority=1 AND (tag=green OR value=20) — none match
        self::assertCount(0, $rows);
    }

    #[Test]
    public function nested_start_end_where_group(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->startWhereGroup()
                ->where('tag', 'red')
                ->orWhere('tag', 'blue')
            ->endWhereGroup()
            ->where('value', 25, WhereCondition::GREATER_THAN)
            ->execute()->fetchAllAssoc();
        // (red OR blue) AND value>25 → Charlie 30, Echo 50
        self::assertCount(2, $rows);
    }

    #[Test]
    public function whereRaw_throws_permanently(): void
    {
        $this->expectException(MongoUnsupportedException::class);
        $this->drv->select(self::COLLECTION)->whereRaw('foo = 1')->execute();
    }

    #[Test]
    public function like_translates_to_anchored_regex(): void
    {
        $this->seedReadFixture();
        $rows = $this->drv->select(self::COLLECTION)
            ->where('name', '%Cha%', WhereCondition::LIKE)
            ->execute()->fetchAllAssoc();
        self::assertCount(1, $rows);
        self::assertSame('Charlie', $rows[0]['name']);
    }

    // ─── Z2 — write path + counters ───

    #[Test]
    public function insert_issues_sequential_counter_id(): void
    {
        $this->drv->insert(self::COLLECTION)->fields(['name' => 'Alpha'])->execute();
        self::assertSame(1, $this->drv->lastInsertId());

        $this->drv->insert(self::COLLECTION)->fields(['name' => 'Bravo'])->execute();
        self::assertSame(2, $this->drv->lastInsertId());
    }

    #[Test]
    public function insert_honours_explicit_id_without_advancing_counter(): void
    {
        $this->drv->insert(self::COLLECTION)->fields(['name' => 'Alpha'])->execute();
        $this->drv->insert(self::COLLECTION)->fields(['id' => 999, 'name' => 'CallerId'])->execute();
        self::assertSame(999, $this->drv->lastInsertId());

        $this->drv->insert(self::COLLECTION)->fields(['name' => 'Counter2'])->execute();
        self::assertSame(2, $this->drv->lastInsertId(), 'counter independent of explicit ids');
    }

    #[Test]
    public function insert_bulk_form_with_list_of_rows(): void
    {
        $this->drv->insert(self::COLLECTION)->fields([
            ['name' => 'BulkOne'],
            ['name' => 'BulkTwo'],
            ['name' => 'BulkThree'],
        ])->execute();
        self::assertSame(3, $this->drv->lastInsertId());

        $this->drv->select(self::COLLECTION)->toCountQuery()->execute();
        self::assertSame(3, $this->drv->fetchColumn(0));
    }

    #[Test]
    public function update_via_filter_modifies_matching_rows(): void
    {
        $this->drv->insert(self::COLLECTION)->fields(['tag' => 'red', 'priority' => 1])->execute();
        $this->drv->insert(self::COLLECTION)->fields(['tag' => 'red', 'priority' => 1])->execute();
        $this->drv->insert(self::COLLECTION)->fields(['tag' => 'blue', 'priority' => 1])->execute();

        $this->drv->update(self::COLLECTION)->fields(['priority' => 99])->where('tag', 'red')->execute();

        $rows = $this->drv->select(self::COLLECTION)->where('tag', 'red')->execute()->fetchAllAssoc();
        self::assertCount(2, $rows);
        foreach ($rows as $r) {
            self::assertSame(99, $r['priority']);
        }
    }

    #[Test]
    public function update_single_by_id(): void
    {
        $this->drv->insert(self::COLLECTION)->fields(['id' => 42, 'name' => 'Original'])->execute();
        $this->drv->update(self::COLLECTION)->fields(['name' => 'Updated'])->where('id', 42)->execute();

        $row = $this->drv->select(self::COLLECTION)->where('id', 42)->execute()->fetchAssoc();
        self::assertSame('Updated', $row['name']);
    }

    #[Test]
    public function delete_via_filter_removes_matching_rows(): void
    {
        $this->drv->insert(self::COLLECTION)->fields([
            ['tag' => 'red'], ['tag' => 'blue'], ['tag' => 'blue'], ['tag' => 'red'],
        ])->execute();

        $this->drv->delete(self::COLLECTION)->where('tag', 'blue')->execute();

        $this->drv->select(self::COLLECTION)->toCountQuery()->execute();
        self::assertSame(2, $this->drv->fetchColumn(0));
    }

    // ─── Z2 — escape hatches ───

    #[Test]
    public function pipeline_runs_aggregation(): void
    {
        $this->drv->insert(self::COLLECTION)->fields([
            ['tag' => 'red'], ['tag' => 'red'], ['tag' => 'blue'],
        ])->execute();

        $result = $this->drv->pipeline(self::COLLECTION, [
            ['$group' => ['_id' => '$tag', 'count' => ['$sum' => 1]]],
            ['$sort'  => ['count' => -1]],
        ]);

        self::assertCount(2, $result);
        self::assertSame('red', $result[0]['_id']);
        self::assertSame(2, $result[0]['count']);
    }

    #[Test]
    public function aggregate_is_alias_of_pipeline(): void
    {
        $this->drv->insert(self::COLLECTION)->fields(['priority' => 99])->execute();
        $this->drv->insert(self::COLLECTION)->fields(['priority' => 99])->execute();

        $result = $this->drv->aggregate(self::COLLECTION, [
            ['$match' => ['priority' => 99]],
            ['$count' => 'matched'],
        ]);

        self::assertSame(2, $result[0]['matched']);
    }

    #[Test]
    public function ensure_index_creates_named_index(): void
    {
        $this->drv->ensureIndex(self::COLLECTION, ['name' => 1], ['unique' => true, 'name' => 'uq_name']);

        $names = array_map(
            static fn($ix) => $ix->getName(),
            iterator_to_array($this->client->selectDatabase($this->database)
                ->selectCollection(self::COLLECTION)->listIndexes())
        );
        self::assertContains('uq_name', $names);
    }

    #[Test]
    public function ensure_index_is_idempotent(): void
    {
        $this->drv->ensureIndex(self::COLLECTION, ['name' => 1], ['unique' => true, 'name' => 'uq_name']);
        $this->drv->ensureIndex(self::COLLECTION, ['name' => 1], ['unique' => true, 'name' => 'uq_name']);
        // No exception ⇒ pass.
        $this->expectNotToPerformAssertions();
    }

    #[Test]
    public function drop_index_removes_by_name(): void
    {
        $this->drv->ensureIndex(self::COLLECTION, ['name' => 1], ['unique' => true, 'name' => 'uq_name']);
        $this->drv->dropIndex(self::COLLECTION, 'uq_name');

        $names = array_map(
            static fn($ix) => $ix->getName(),
            iterator_to_array($this->client->selectDatabase($this->database)
                ->selectCollection(self::COLLECTION)->listIndexes())
        );
        self::assertNotContains('uq_name', $names);
    }

    #[Test]
    public function bulk_write_returns_total_affected_count(): void
    {
        $affected = $this->drv->bulkWrite(self::COLLECTION, [
            ['insertOne'  => [['id' => 100, 'tag' => 'orange']]],
            ['insertOne'  => [['id' => 101, 'tag' => 'orange']]],
            ['updateMany' => [['tag' => 'orange'], ['$set' => ['priority' => 42]]]],
            ['deleteOne'  => [['id' => 100]]],
        ]);

        self::assertSame(5, $affected, '2 insert + 2 modified + 1 delete = 5');

        $rows = $this->drv->select(self::COLLECTION)->where('tag', 'orange')->execute()->fetchAllAssoc();
        self::assertCount(1, $rows);
        self::assertSame(42, $rows[0]['priority']);
        self::assertSame(101, $rows[0]['id']);
    }
}
