use strict;
use warnings;
use Test::More;
use DBIx::Fast;
use DBIx::Fast::Cache;

# Regression tests for the cache subsystem:
#  - rank 11: build_key must not collide when a bind value contains the
#    separator byte, or forge the undef sentinel.
#  - rank 10: invalidate() must prune a key from ALL its tag buckets.
#  - rank 7:  cached hash()/val() must cache negative (undef) results.
#  - rank 8:  a public $db->invalidate(@tags) helper for raw-SQL writes.

eval "use DBD::SQLite 1.74";
plan skip_all => "DBD::SQLite 1.74 required" if $@;
eval { require LRU::Cache; 1 }
  or plan skip_all => 'LRU::Cache required for the default cache backend';

subtest 'rank 11: build_key is collision-resistant' => sub {
    my $c = DBIx::Fast::Cache->new;
    isnt($c->build_key('S', ["a\0b"]), $c->build_key('S', ["a", "b"]),
         'a NUL inside one value does not collide with a two-value split');
    isnt($c->build_key('S', ["a", "b"]), $c->build_key('S', ["ab"]),
         'the value boundary is unambiguous');
    isnt($c->build_key('S', [undef]), $c->build_key('S', ['U']),
         'undef is not forgeable by a literal string value');
    is($c->build_key('S', [42]), $c->build_key('S', [42]),
       'still deterministic for equal inputs');
};

subtest 'rank 10: invalidate prunes sibling tag buckets' => sub {
    my $c = DBIx::Fast::Cache->new(default_ttl => 0);
    $c->set('k', 'v', tags => ['a', 'b']);
    is($c->invalidate('a'), 1, 'invalidate(a) drops the key');
    is($c->get('k'), undef, 'key is gone from the store');
    is($c->invalidate('b'), 0,
       'sibling tag b holds no stale reference to the dropped key');
};

subtest 'rank 7: cached hash()/val() cache negative (undef) results' => sub {
    my $db = DBIx::Fast->new(
        dsn        => 'dbi:SQLite:dbname=:memory:',
        driver     => 'SQLite',
        RaiseError => 1,
        PrintError => 0,
        cache      => { default_ttl => 60 },
    );
    $db->exec('CREATE TABLE p (id INTEGER PRIMARY KEY, sku TEXT)');
    $db->tracker;   # enable query tracking to count REAL DB executions

    # NB: Cache::get increments the hit counter even for a cached undef, so the
    # stats hit/miss counters do NOT reveal the re-query bug. Count actual DB
    # queries via the profiler instead.
    my $n0 = scalar @{ $db->tracker->queries };
    my $r1 = $db->cached->hash('SELECT * FROM p WHERE sku = ?', 'NONE');
    my $r2 = $db->cached->hash('SELECT * FROM p WHERE sku = ?', 'NONE');
    is($r1, undef, 'missing row returns undef');
    is($r2, undef, 'still undef on second call');
    is(scalar @{ $db->tracker->queries } - $n0, 1,
       'repeated negative hash() hits the DB only once (undef is cached)');

    my $n1 = scalar @{ $db->tracker->queries };
    $db->cached->val('SELECT sku FROM p WHERE id = ?', 999);
    $db->cached->val('SELECT sku FROM p WHERE id = ?', 999);
    is(scalar @{ $db->tracker->queries } - $n1, 1,
       'repeated negative val() hits the DB only once');
};

subtest 'rank 8: $db->invalidate(@tags) clears after a raw exec() write' => sub {
    my $db = DBIx::Fast->new(
        dsn        => 'dbi:SQLite:dbname=:memory:',
        driver     => 'SQLite',
        RaiseError => 1,
        PrintError => 0,
        cache      => { default_ttl => 60 },
    );
    $db->exec('CREATE TABLE p (id INTEGER PRIMARY KEY, n INTEGER)');
    $db->exec('INSERT INTO p (id, n) VALUES (1, 1)');

    is($db->cached(tag => 'p')->val('SELECT n FROM p WHERE id = ?', 1), 1,
       'first read cached');

    # Raw exec() write bypasses the CRUD auto-invalidation (documented gap).
    $db->exec('UPDATE p SET n = 2 WHERE id = 1');
    is($db->cached(tag => 'p')->val('SELECT n FROM p WHERE id = ?', 1), 1,
       'raw write leaves a stale cached read');

    # The public helper clears it.
    $db->invalidate('p');
    is($db->cached(tag => 'p')->val('SELECT n FROM p WHERE id = ?', 1), 2,
       '$db->invalidate(@tags) refreshes the cache after a raw write');
};

subtest 'R2.2: cache key includes the method (no cross-method poisoning)' => sub {
    my $db = DBIx::Fast->new(
        dsn        => 'dbi:SQLite:dbname=:memory:',
        driver     => 'SQLite',
        RaiseError => 1,
        PrintError => 0,
        cache      => { default_ttl => 60 },
    );
    $db->exec('CREATE TABLE p (id INTEGER PRIMARY KEY, name TEXT)');
    $db->exec("INSERT INTO p (id, name) VALUES (1, 'x')");

    my $sql  = 'SELECT * FROM p WHERE id = ?';
    my $rows = $db->cached->all($sql, 1);    # caches an arrayref-of-rows
    my $row  = $db->cached->hash($sql, 1);   # must NOT collide with all()'s entry

    is(ref $rows, 'ARRAY', 'all() returns an arrayref');
    is(ref $row,  'HASH',  'hash() returns a hashref (not all()\'s cached arrayref)');
    is((ref $row eq 'HASH' ? $row->{name} : undef), 'x',
       'hash() returns the correct row');
};

subtest 'R2.6: invalidate does not count keys evicted by the backend' => sub {
    my $c = DBIx::Fast::Cache->new(max_size => 2, default_ttl => 0);
    $c->set("k$_", "v$_", tags => ['t']) for 1 .. 50;   # backend keeps ~2
    my $n = $c->invalidate('t');
    cmp_ok($n, '<=', 2,
       'invalidate counts only keys still live in the store, not evicted ones');
};

done_testing;
